Compare commits

..

8 Commits

Author SHA1 Message Date
Marcel
b1b8fa4bed docs: note honest date formatter, title formatter and drift fixture
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 3m17s
CI / OCR Service Tests (pull_request) Successful in 21s
CI / Backend Unit Tests (pull_request) Successful in 3m47s
CI / fail2ban Regex (pull_request) Successful in 43s
CI / Semgrep Security Scan (pull_request) Successful in 21s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m1s
Documents DocumentTitleFormatter in the document-management C4 diagram and adds
an "honest precision display" row to the CONTRIBUTING date-handling table,
pointing at formatDocumentDate / <DocumentDate>, the shared
docs/date-label-fixtures.json drift guard, and the {@html} escaping rule.

Closes #666

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 12:08:00 +02:00
Marcel
2bd5c82826 ci: guard against rendering meta_date_raw via {@html}
Adds a grep guard (with self-test) that fails the build if any {@html ...}
expression references metaDateRaw/documentDateRaw/rawDate. meta_date_raw is
untrusted verbatim spreadsheet text and must render via Svelte default
escaping (CWE-79). Addresses Nora's regression-guard request from #666 — a
single component test cannot catch a future {@html} introduced elsewhere.

Refs #666

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 12:05:17 +02:00
Marcel
7245571ea8 feat(document): edit document date precision, end and raw
Adds the edit-form date-precision controls to WhoWhenSection: a labelled
precision <select> (min 48px touch target for senior authors), a conditionally
revealed end-date field (only for RANGE, announced via aria-live=polite), and
the verbatim raw cell as labelled read-only static text (not a disabled input).
Fields submit as metaDatePrecision/metaDateEnd/metaDateRaw and flow through the
existing PUT form action.

Backend: DocumentService.updateDocument now persists the three DTO fields (they
existed since #671 but were never applied), so the new controls are real, not
decorative — addresses Nora's "a client <select> constrains nothing" note for
the persistence half. Server-side enum/end>=start validation remains #671's
scope.

Refs #666

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 12:04:14 +02:00
Marcel
b56b9dfa74 feat(frontend): render honest precision dates in detail, list and search
Wires formatDocumentDate/DocumentDate into the read sites: the document
detail top bar + metadata drawer (the drawer shows the visible "Originaltext:"
raw line for UNKNOWN/SEASON/APPROX), the search/list rows (DocumentRow,
mobile + desktop), and the document multi-select dropdown label. A MONTH or
SEASON document now reads "Juni 1916"/"Sommer 1916" everywhere instead of a
fabricated day.

Adds metaDatePrecision to the DocumentRow/DocumentMultiSelect test fixtures
(required on DocumentListItem since #671) and updates the multi-select label
assertion to the honest long date.

Refs #666

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 11:56:49 +02:00
Marcel
6538c9e59a feat(frontend): add accessible DocumentDate render component
Wraps formatDocumentDate with the accessible presentation layer: a non-color
UNKNOWN cue (decorative calendar-with-question icon, aria-hidden, since the
visible "Datum unbekannt" text is the textual cue — WCAG 1.4.1), and the
verbatim meta_date_raw shown as a VISIBLE secondary "Originaltext: …" line for
UNKNOWN/SEASON/APPROX (WCAG 1.4.13, not tooltip-only). raw is rendered via
Svelte default escaping, never {@html} (CWE-79); a component test asserts an
angle-bracket raw value stays inert. Browser test is CI-only.

Refs #666

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 11:49:35 +02:00
Marcel
c816934391 feat(importing): build honest precision-aware document import titles
Wires DocumentTitleFormatter into DocumentImporter.buildDocument: the title
now reads "{index} – {honest date label} – {location}", so a MONTH-precision
letter's title says "Juni 1916" instead of a fabricated "1. Juni 1916", and an
UNKNOWN-date row keeps a bare index title. buildTitle stays under 20 lines by
delegating to the shared formatter (single source of truth with the UI label).

Restores the date+location title behavior that the old MassImportService had
(it appended a full GERMAN_DATE day) but now at the honest precision.

Refs #666

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 11:47:51 +02:00
Marcel
1caae38946 feat(importing): add precision-aware DocumentTitleFormatter
Adds the Java half of the honest date label — formatTitleDate(date,
precision, end, raw) — mirroring the frontend formatDocumentDate rules so an
import title never shows a precision the data lacks (MONTH → "Juni 1916", not
a fabricated day). Both implementations are pinned to the shared
docs/date-label-fixtures.json table, which this test asserts case-by-case, so
they cannot drift. Java's de CLDR renders the same "Jan."/"Dez." abbreviations
and en-dash the TS side produces.

Refs #666

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 11:45:57 +02:00
Marcel
f2a74a6064 feat(frontend): add precision-aware document date formatter
Adds formatDocumentDate — a pure, branch-per-precision label function that
renders a document date at exactly the precision the data claims (DAY → full
date, MONTH → "Juni 1916", SEASON → localized season word, YEAR → "1916",
APPROX → "ca. 1916", RANGE with collapse/expand/open-ended, UNKNOWN → "Datum
unbekannt"). Delegates to the existing date.ts helpers (shared T12:00:00
convention) and routes every localized word through Paraglide.

A shared docs/date-label-fixtures.json table is asserted by this spec and will
be asserted by the Java title formatter, as the drift guard requested in
review (Markus/Sara). Adds de/en/es precision/season/edit-form i18n keys.

Assumption: SEASON structured label is localized per locale (Decision 4),
with the verbatim raw cell preserved as a separate secondary line by callers.

Refs #666

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 11:43:32 +02:00
28 changed files with 990 additions and 26 deletions

View File

@@ -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: |

View File

@@ -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)

View File

@@ -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());

View File

@@ -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) {

View File

@@ -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;
};
}
}

View File

@@ -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

View File

@@ -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,

View File

@@ -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());
}
}

View File

@@ -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")

View 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"
}
]
}

View File

@@ -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...",

View File

@@ -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...",

View File

@@ -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...",

View 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>

View 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();
});
});

View File

@@ -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 ?? ''}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 () => {

View File

@@ -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>

View File

@@ -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: [],

View File

@@ -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: [],

View File

@@ -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}

View File

@@ -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>

View File

@@ -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) -->

View File

@@ -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();
});
});

View 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');
});
});

View 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';
}