feat(dates): honest precision-aware date rendering (Phase 4, #666) #677
@@ -65,6 +65,29 @@ 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.
|
||||
# The token list MUST cover every variable that carries the raw value:
|
||||
# DocumentDate.svelte exposes it via the `raw` prop, so `\braw\b` is included.
|
||||
# Grow this list whenever a new raw-bearing variable name is introduced.
|
||||
pattern='\{@html[^}]*(metaDateRaw|documentDateRaw|rawDate|\braw\b)'
|
||||
# Self-test: the regex must catch the dangerous forms 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 '{@html raw}\n' | grep -qP "$pattern" \
|
||||
|| { echo "FAIL: guard self-test — regex missed the unsafe {@html raw} form (DocumentDate prop)"; 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,7 @@ public class DocumentService {
|
||||
// 1. Einfache Felder Update
|
||||
doc.setTitle(dto.getTitle());
|
||||
doc.setDocumentDate(dto.getDocumentDate());
|
||||
applyDatePrecision(doc, dto);
|
||||
doc.setLocation(dto.getLocation());
|
||||
doc.setTranscription(dto.getTranscription());
|
||||
doc.setSummary(dto.getSummary());
|
||||
@@ -446,6 +447,25 @@ public class DocumentService {
|
||||
return saved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the three date-precision fields only when the DTO carries them.
|
||||
* A null field means "not submitted" — overwriting the stored value with null
|
||||
* would fabricate a precision the user never chose, the exact dishonesty #666
|
||||
* exists to prevent. A row with a genuinely-unknown precision must keep it when
|
||||
* an unrelated edit (e.g. a location typo) is saved.
|
||||
*/
|
||||
private void applyDatePrecision(Document doc, DocumentUpdateDTO dto) {
|
||||
if (dto.getMetaDatePrecision() != null) {
|
||||
doc.setMetaDatePrecision(dto.getMetaDatePrecision());
|
||||
}
|
||||
if (dto.getMetaDateEnd() != null) {
|
||||
doc.setMetaDateEnd(dto.getMetaDateEnd());
|
||||
}
|
||||
if (dto.getMetaDateRaw() != null) {
|
||||
doc.setMetaDateRaw(dto.getMetaDateRaw());
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Document updateDocumentTags(UUID docId, List<String> tagNames) {
|
||||
Document doc = documentRepository.findById(docId)
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -294,6 +294,34 @@ class DocumentControllerTest {
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void updateDocument_bindsPrecisionFormFields_toDTO() throws Exception {
|
||||
// Pins the wire contract: the edit form's metaDatePrecision / metaDateEnd /
|
||||
// metaDateRaw multipart field names must bind to DocumentUpdateDTO. A rename
|
||||
// on either side silently drops the precision edit; this captures the DTO.
|
||||
UUID id = UUID.randomUUID();
|
||||
Document doc = Document.builder().id(id).title("Brief").originalFilename("brief.pdf").build();
|
||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||
|
||||
org.mockito.ArgumentCaptor<DocumentUpdateDTO> captor =
|
||||
org.mockito.ArgumentCaptor.forClass(DocumentUpdateDTO.class);
|
||||
when(documentService.updateDocument(eq(id), captor.capture(), any(), any())).thenReturn(doc);
|
||||
|
||||
mockMvc.perform(multipart("/api/documents/" + id)
|
||||
.param("metaDatePrecision", "RANGE")
|
||||
.param("metaDateEnd", "1917-01-11")
|
||||
.param("metaDateRaw", "10.–11. Januar 1917")
|
||||
.with(req -> { req.setMethod("PUT"); return req; }).with(csrf()))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
DocumentUpdateDTO bound = captor.getValue();
|
||||
org.assertj.core.api.Assertions.assertThat(bound.getMetaDatePrecision()).isEqualTo(DatePrecision.RANGE);
|
||||
org.assertj.core.api.Assertions.assertThat(bound.getMetaDateEnd())
|
||||
.isEqualTo(java.time.LocalDate.of(1917, 1, 11));
|
||||
org.assertj.core.api.Assertions.assertThat(bound.getMetaDateRaw()).isEqualTo("10.–11. Januar 1917");
|
||||
}
|
||||
|
||||
// ─── DELETE /api/documents/{id} ──────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
|
||||
@@ -144,6 +144,53 @@ 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");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateDocument_preservesStoredPrecision_whenDtoOmitsIt() throws Exception {
|
||||
// Editing a doc (e.g. fixing a location typo) without touching the precision
|
||||
// controls must NOT fabricate a precision. The form omits the three precision
|
||||
// fields → they arrive null on the DTO → the stored values must be preserved.
|
||||
UUID id = UUID.randomUUID();
|
||||
Document doc = Document.builder()
|
||||
.id(id)
|
||||
.metaDatePrecision(DatePrecision.MONTH)
|
||||
.metaDateEnd(LocalDate.of(1916, 6, 30))
|
||||
.metaDateRaw("Juni 1916")
|
||||
.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.setLocation("Berlin"); // unrelated edit; precision fields left null
|
||||
|
||||
documentService.updateDocument(id, dto, null, null);
|
||||
|
||||
assertThat(doc.getMetaDatePrecision()).isEqualTo(DatePrecision.MONTH);
|
||||
assertThat(doc.getMetaDateEnd()).isEqualTo(LocalDate.of(1916, 6, 30));
|
||||
assertThat(doc.getMetaDateRaw()).isEqualTo("Juni 1916");
|
||||
}
|
||||
|
||||
// ─── 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")
|
||||
|
||||
140
docs/date-label-fixtures.json
Normal file
140
docs/date-label-fixtures.json
Normal file
@@ -0,0 +1,140 @@
|
||||
{
|
||||
"_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). The 'cases' array holds the GERMAN (de) canonical form and is asserted by BOTH suites — that is the Java<->TS drift guard (en-dash vs hyphen, 'ca.' vs 'circa', season words, range collapse). The Java title formatter intentionally renders German server-side (import titles are always German); only the TS UI formatter is locale-aware, so 'localeCases' (en/es month-name output) is asserted by the TS spec ONLY and must NOT be fed to the Java test. Do not edit one side's expectation without editing this file and the relevant test(s). Season->month mapping note: the Python import normalizer (tools/import-normalizer) is the UPSTREAM authority for which representative month a season maps to (4/7/10/1); both formatters mirror it but it sits OUTSIDE this Java<->TS guard, so a normalizer change is not caught here. 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"
|
||||
}
|
||||
],
|
||||
"localeComment": "TS-only locale parity for the read path (the younger phone audience may use en/es). Asserted ONLY by documentDate.spec.ts — the Java title formatter is German-only by design, so these MUST NOT be fed to DocumentTitleFormatterTest. Each case pins the localized month-name output for DAY and MONTH so a locale regression (e.g. a future de-DE hard-coding) is caught by the drift table, not just by ad-hoc tests.",
|
||||
"localeCases": [
|
||||
{
|
||||
"name": "DAY in English renders the English month name",
|
||||
"precision": "DAY",
|
||||
"anchor": "1943-12-24",
|
||||
"end": null,
|
||||
"raw": null,
|
||||
"locale": "en",
|
||||
"expected": "December 24, 1943"
|
||||
},
|
||||
{
|
||||
"name": "DAY in Spanish renders the Spanish month name",
|
||||
"precision": "DAY",
|
||||
"anchor": "1943-12-24",
|
||||
"end": null,
|
||||
"raw": null,
|
||||
"locale": "es",
|
||||
"expected": "24 de diciembre de 1943"
|
||||
},
|
||||
{
|
||||
"name": "MONTH in English renders the English month name, never a day",
|
||||
"precision": "MONTH",
|
||||
"anchor": "1916-06-01",
|
||||
"end": null,
|
||||
"raw": "Juni 1916",
|
||||
"locale": "en",
|
||||
"expected": "June 1916"
|
||||
},
|
||||
{
|
||||
"name": "MONTH in Spanish renders the Spanish month name, never a day",
|
||||
"precision": "MONTH",
|
||||
"anchor": "1916-06-01",
|
||||
"end": null,
|
||||
"raw": "Juni 1916",
|
||||
"locale": "es",
|
||||
"expected": "junio de 1916"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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,13 +2,23 @@
|
||||
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'];
|
||||
|
||||
/**
|
||||
* Exactly the fields this picker reads — id for selection/dedup, the rest for
|
||||
* the honest date label. A full `Document` and a `DocumentListItem` are both
|
||||
* structurally assignable, so the search results need no cast.
|
||||
*/
|
||||
type DocumentOption = Pick<
|
||||
DocumentListItem,
|
||||
'id' | 'title' | 'documentDate' | 'metaDatePrecision' | 'metaDateEnd'
|
||||
>;
|
||||
|
||||
interface Props {
|
||||
selectedDocuments?: Document[];
|
||||
selectedDocuments?: DocumentOption[];
|
||||
placeholder?: string;
|
||||
hiddenInputName?: string;
|
||||
}
|
||||
@@ -20,7 +30,7 @@ let {
|
||||
}: Props = $props();
|
||||
|
||||
let searchTerm = $state('');
|
||||
let results: Document[] = $state([]);
|
||||
let results: DocumentOption[] = $state([]);
|
||||
let showDropdown = $state(false);
|
||||
let loading = $state(false);
|
||||
let debounceTimer: ReturnType<typeof setTimeout>;
|
||||
@@ -46,11 +56,13 @@ function handleInput() {
|
||||
const res = await fetch(`/api/documents/search?q=${encodeURIComponent(searchTerm)}&size=10`);
|
||||
if (res.ok) {
|
||||
const body: { items: DocumentListItem[] } = await res.json();
|
||||
const docs = body.items.map((it) => ({
|
||||
const docs: DocumentOption[] = body.items.map((it) => ({
|
||||
id: it.id,
|
||||
title: it.title,
|
||||
documentDate: it.documentDate
|
||||
})) as unknown as Document[];
|
||||
documentDate: it.documentDate,
|
||||
metaDatePrecision: it.metaDatePrecision,
|
||||
metaDateEnd: it.metaDateEnd
|
||||
}));
|
||||
results = docs.filter((d) => !selectedDocuments.some((s) => s.id === d.id));
|
||||
}
|
||||
} catch {
|
||||
@@ -61,7 +73,7 @@ function handleInput() {
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function selectDocument(doc: Document) {
|
||||
function selectDocument(doc: DocumentOption) {
|
||||
selectedDocuments = [...selectedDocuments, doc];
|
||||
searchTerm = '';
|
||||
showDropdown = false;
|
||||
@@ -72,9 +84,16 @@ function removeDocument(id: string | undefined) {
|
||||
selectedDocuments = selectedDocuments.filter((d) => d.id !== id);
|
||||
}
|
||||
|
||||
function formatDocLabel(doc: Document): string {
|
||||
if (doc.documentDate) return `${doc.title} · ${formatDate(doc.documentDate, 'short')}`;
|
||||
return doc.title;
|
||||
function formatDocLabel(doc: DocumentOption): string {
|
||||
if (!doc.documentDate) return doc.title;
|
||||
const label = formatDocumentDate(
|
||||
doc.documentDate,
|
||||
doc.metaDatePrecision as DatePrecision,
|
||||
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,20 @@ 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) : '—'}
|
||||
<!-- Product decision (#666): raw provenance (meta_date_raw) is shown on the
|
||||
document DETAIL page, never in list/search rows — list rows surface only the
|
||||
honest label to keep scan-rows compact. showRaw={false} enforces this; the
|
||||
DocumentListItem payload also intentionally omits metaDateRaw. -->
|
||||
{#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 +191,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}
|
||||
|
||||
@@ -46,10 +46,12 @@ describe('DocumentTopBar', () => {
|
||||
await expect.element(page.getByRole('heading', { name: 'brief.pdf' })).toBeVisible();
|
||||
});
|
||||
|
||||
it('renders the short documentDate when one is present', async () => {
|
||||
it('renders the precision-aware long documentDate when one is present', async () => {
|
||||
render(DocumentTopBar, { props: baseProps() });
|
||||
|
||||
await expect.element(page.getByText('15.04.1923')).toBeVisible();
|
||||
// documentDate '1923-04-15' with default DAY precision renders the honest
|
||||
// long German label via formatDocumentDate (Refs #666), not the old short form.
|
||||
await expect.element(page.getByText('15. April 1923')).toBeVisible();
|
||||
});
|
||||
|
||||
it('omits the date paragraph entirely when documentDate is null', async () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -32,10 +32,12 @@ describe('DocumentTopBarTitle', () => {
|
||||
await expect.element(page.getByRole('heading', { name: 'brief.pdf' })).toBeVisible();
|
||||
});
|
||||
|
||||
it('renders the short date format when a documentDate is supplied', async () => {
|
||||
it('renders the precision-aware long date when a documentDate is supplied', async () => {
|
||||
render(DocumentTopBarTitle, { props: baseProps });
|
||||
|
||||
await expect.element(page.getByText('15.04.1923')).toBeVisible();
|
||||
// '1923-04-15' defaults to DAY precision and renders the honest long German
|
||||
// label via formatDocumentDate (Refs #666), not the old short form.
|
||||
await expect.element(page.getByText('15. April 1923')).toBeVisible();
|
||||
});
|
||||
|
||||
it('omits the date paragraph entirely when documentDate is null', async () => {
|
||||
|
||||
@@ -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 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"
|
||||
/>
|
||||
</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();
|
||||
});
|
||||
});
|
||||
|
||||
159
frontend/src/lib/shared/utils/documentDate.spec.ts
Normal file
159
frontend/src/lib/shared/utils/documentDate.spec.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
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;
|
||||
};
|
||||
|
||||
type LocaleFixtureCase = FixtureCase & { locale: string };
|
||||
|
||||
const fixtures = JSON.parse(
|
||||
readFileSync(resolve(process.cwd(), '../docs/date-label-fixtures.json'), 'utf-8')
|
||||
) as { cases: FixtureCase[]; localeCases: LocaleFixtureCase[] };
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// TS-only locale parity (the Java title formatter is German-only by design, so
|
||||
// localeCases are asserted here and never fed to DocumentTitleFormatterTest).
|
||||
describe('formatDocumentDate – shared fixture table (en/es locale parity)', () => {
|
||||
for (const c of fixtures.localeCases) {
|
||||
it(`${c.name} [${c.locale}]`, () => {
|
||||
expect(
|
||||
formatDocumentDate(
|
||||
c.anchor,
|
||||
c.precision as Parameters<typeof formatDocumentDate>[1],
|
||||
c.end,
|
||||
c.raw,
|
||||
c.locale
|
||||
)
|
||||
).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`
|
||||
);
|
||||
});
|
||||
|
||||
// DAY precision must honour the active locale (regression: it was hard-wired
|
||||
// to de-DE, so an English/Spanish reader saw "24. Dezember 1943").
|
||||
it('localizes the DAY month name in English', () => {
|
||||
expect(formatDocumentDate('1943-12-24', 'DAY', null, null, 'en')).toBe(
|
||||
new Intl.DateTimeFormat('en', { day: 'numeric', month: 'long', year: 'numeric' }).format(
|
||||
new Date('1943-12-24T12:00:00')
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it('localizes the DAY month name in Spanish', () => {
|
||||
expect(formatDocumentDate('1943-12-24', 'DAY', null, null, 'es')).toBe(
|
||||
new Intl.DateTimeFormat('es', { day: 'numeric', month: 'long', year: 'numeric' }).format(
|
||||
new Date('1943-12-24T12:00:00')
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it('localizes the MONTH month name in English', () => {
|
||||
expect(formatDocumentDate('1916-06-01', 'MONTH', null, 'Juni 1916', 'en')).toBe(
|
||||
new Intl.DateTimeFormat('en', { month: 'long', year: 'numeric' }).format(
|
||||
new Date('1916-06-01T12:00:00')
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it('localizes the MONTH month name in Spanish', () => {
|
||||
expect(formatDocumentDate('1916-06-01', 'MONTH', null, 'Juni 1916', 'es')).toBe(
|
||||
new Intl.DateTimeFormat('es', { month: 'long', year: 'numeric' }).format(
|
||||
new Date('1916-06-01T12:00:00')
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 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');
|
||||
});
|
||||
});
|
||||
169
frontend/src/lib/shared/utils/documentDate.ts
Normal file
169
frontend/src/lib/shared/utils/documentDate.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { 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.
|
||||
*
|
||||
* Every structured part (month name, day-of-month text, season word, prefixes)
|
||||
* is rendered in the active `locale` — DAY, MONTH and RANGE all go through
|
||||
* `Intl.DateTimeFormat(locale, …)` and the localized words through Paraglide —
|
||||
* so an `en`/`es` reader never sees a German month name. The `T12:00:00`
|
||||
* UTC-safety convention is kept via {@link noon}.
|
||||
*
|
||||
* 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 longDate(iso, locale);
|
||||
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 longDate(iso: string, locale: string): string {
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
}).format(noon(iso));
|
||||
}
|
||||
|
||||
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