From 8555193a79b9dc1704c3b5a9b2e41461e5f03577 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 26 Mar 2026 15:17:16 +0100 Subject: [PATCH 1/8] feat(filename): add parseFilename utility with full-pattern-only matching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Supports four patterns: date_lastname_firstname and lastname_firstname_date, both with ISO (YYYY-MM-DD) and compact (YYYYMMDD) date formats. Returns dateIso, personName and a formatted suggestedTitle. Partial matches are rejected — unrecognised filenames return {}. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/utils/filename.spec.ts | 85 +++++++++++++++++++++++++ frontend/src/lib/utils/filename.ts | 56 ++++++++++++++++ 2 files changed, 141 insertions(+) create mode 100644 frontend/src/lib/utils/filename.spec.ts create mode 100644 frontend/src/lib/utils/filename.ts diff --git a/frontend/src/lib/utils/filename.spec.ts b/frontend/src/lib/utils/filename.spec.ts new file mode 100644 index 00000000..77c28d6b --- /dev/null +++ b/frontend/src/lib/utils/filename.spec.ts @@ -0,0 +1,85 @@ +import { describe, it, expect } from 'vitest'; +import { parseFilename, stripExtension } from './filename'; + +describe('parseFilename', () => { + describe('YYYY-MM-DD_Lastname_Firstname pattern', () => { + it('extracts date and name', () => { + expect(parseFilename('1965-03-12_Mueller_Hans.pdf')).toEqual({ + dateIso: '1965-03-12', + personName: 'Hans Mueller', + suggestedTitle: 'Hans Mueller (12.03.1965)' + }); + }); + + it('handles umlauts in names', () => { + const result = parseFilename('2024-01-15_Müller_Jürgen.pdf'); + expect(result.personName).toBe('Jürgen Müller'); + }); + }); + + describe('YYYYMMDD_Lastname_Firstname pattern', () => { + it('extracts date and name', () => { + expect(parseFilename('19650312_Mueller_Hans.pdf')).toEqual({ + dateIso: '1965-03-12', + personName: 'Hans Mueller', + suggestedTitle: 'Hans Mueller (12.03.1965)' + }); + }); + }); + + describe('Lastname_Firstname_YYYY-MM-DD pattern', () => { + it('extracts date and name', () => { + expect(parseFilename('Mueller_Hans_1965-03-12.pdf')).toEqual({ + dateIso: '1965-03-12', + personName: 'Hans Mueller', + suggestedTitle: 'Hans Mueller (12.03.1965)' + }); + }); + }); + + describe('Lastname_Firstname_YYYYMMDD pattern', () => { + it('extracts date and name', () => { + expect(parseFilename('Mueller_Hans_19650312.pdf')).toEqual({ + dateIso: '1965-03-12', + personName: 'Hans Mueller', + suggestedTitle: 'Hans Mueller (12.03.1965)' + }); + }); + }); + + describe('non-matching filenames', () => { + it('returns empty for date-only filename', () => { + expect(parseFilename('1965-03-12.pdf')).toEqual({}); + }); + + it('returns empty for name-only filename', () => { + expect(parseFilename('Mueller_Hans.pdf')).toEqual({}); + }); + + it('returns empty for unstructured filename', () => { + expect(parseFilename('scan_001.pdf')).toEqual({}); + }); + + it('returns empty for three name segments without date', () => { + expect(parseFilename('Mueller_Hans_Juergen.pdf')).toEqual({}); + }); + + it('returns empty for filename without extension', () => { + expect(parseFilename('1965-03-12_Mueller_Hans')).toEqual({}); + }); + }); +}); + +describe('stripExtension', () => { + it('removes the extension', () => { + expect(stripExtension('document.pdf')).toBe('document'); + }); + + it('removes only the last extension', () => { + expect(stripExtension('archive.tar.gz')).toBe('archive.tar'); + }); + + it('leaves names without extension unchanged', () => { + expect(stripExtension('nodotfile')).toBe('nodotfile'); + }); +}); diff --git a/frontend/src/lib/utils/filename.ts b/frontend/src/lib/utils/filename.ts new file mode 100644 index 00000000..7df5213c --- /dev/null +++ b/frontend/src/lib/utils/filename.ts @@ -0,0 +1,56 @@ +import { isoToGerman } from './date'; + +export interface FilenameParseResult { + /** ISO format: YYYY-MM-DD */ + dateIso?: string; + /** "Firstname Lastname" — order reversed from filename convention */ + personName?: string; + /** Ready-to-use title, e.g. "Hans Mueller (12.03.1965)" */ + suggestedTitle?: string; +} + +// Full-match patterns only. Name segments use Unicode letters (\p{L}) to cover umlauts etc. +// Order: date_lastname_firstname +const P_DATE_ISO_NAME = /^(\d{4}-\d{2}-\d{2})_(\p{L}+)_(\p{L}+)\.[^.]+$/u; +const P_DATE_COMPACT_NAME = /^(\d{8})_(\p{L}+)_(\p{L}+)\.[^.]+$/u; +// Order: lastname_firstname_date +const P_NAME_DATE_ISO = /^(\p{L}+)_(\p{L}+)_(\d{4}-\d{2}-\d{2})\.[^.]+$/u; +const P_NAME_DATE_COMPACT = /^(\p{L}+)_(\p{L}+)_(\d{8})\.[^.]+$/u; + +function compactToIso(compact: string): string { + return `${compact.slice(0, 4)}-${compact.slice(4, 6)}-${compact.slice(6, 8)}`; +} + +export function parseFilename(filename: string): FilenameParseResult { + let dateIso: string; + let lastName: string; + let firstName: string; + + let m: RegExpMatchArray | null; + + if ((m = P_DATE_ISO_NAME.exec(filename))) { + [, dateIso, lastName, firstName] = m; + } else if ((m = P_DATE_COMPACT_NAME.exec(filename))) { + dateIso = compactToIso(m[1]); + lastName = m[2]; + firstName = m[3]; + } else if ((m = P_NAME_DATE_ISO.exec(filename))) { + lastName = m[1]; + firstName = m[2]; + dateIso = m[3]; + } else if ((m = P_NAME_DATE_COMPACT.exec(filename))) { + lastName = m[1]; + firstName = m[2]; + dateIso = compactToIso(m[3]); + } else { + return {}; + } + + const personName = `${firstName} ${lastName}`; + const suggestedTitle = `${personName} (${isoToGerman(dateIso!)})`; + return { dateIso: dateIso!, personName, suggestedTitle }; +} + +export function stripExtension(filename: string): string { + return filename.replace(/\.[^/.]+$/, ''); +} -- 2.49.1 From 078bc1c8862897cf2b834fe3b75a335b00b85b44 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 26 Mar 2026 15:17:47 +0100 Subject: [PATCH 2/8] feat(new-doc): pre-fill date, sender and title from parsed filename When a file is selected on the new document page, parseFilename runs on the filename and suggests date, sender name and title via the new suggestedDateIso / suggestedSenderName / suggestedTitle props. Each suggestion is applied only while the respective field is still clean (not dirty), so manual input is never overwritten. Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/components/PersonTypeahead.svelte | 9 ++++++++ .../document/DescriptionSection.svelte | 21 +++++++++++++++++-- .../components/document/WhoWhenSection.svelte | 15 ++++++++++++- .../src/routes/documents/new/+page.svelte | 13 ++++++++++-- .../documents/new/FileSectionNew.svelte | 13 ++++++++++++ 5 files changed, 66 insertions(+), 5 deletions(-) diff --git a/frontend/src/lib/components/PersonTypeahead.svelte b/frontend/src/lib/components/PersonTypeahead.svelte index 91df7d02..5196108a 100644 --- a/frontend/src/lib/components/PersonTypeahead.svelte +++ b/frontend/src/lib/components/PersonTypeahead.svelte @@ -9,6 +9,7 @@ interface Props { label: string; value?: string; initialName?: string; + suggestedName?: string; restrictToCorrespondentsOf?: string; onchange?: (value: string) => void; } @@ -18,12 +19,20 @@ let { label, value = $bindable(''), initialName = '', + suggestedName = '', restrictToCorrespondentsOf, onchange }: Props = $props(); let searchTerm = $state(initialName); +$effect(() => { + const suggested = suggestedName; + if (suggested && !untrack(() => value)) { + searchTerm = suggested; + } +}); + let results: Person[] = $state([]); let showDropdown = $state(false); let loading = $state(false); diff --git a/frontend/src/lib/components/document/DescriptionSection.svelte b/frontend/src/lib/components/document/DescriptionSection.svelte index 10cda974..6d2c13c4 100644 --- a/frontend/src/lib/components/document/DescriptionSection.svelte +++ b/frontend/src/lib/components/document/DescriptionSection.svelte @@ -1,4 +1,5 @@
@@ -33,7 +46,11 @@ let { id="title" type="text" name="title" - value={initialTitle} + value={titleValue} + oninput={(e) => { + titleValue = (e.target as HTMLInputElement).value; + titleDirty = true; + }} required={titleRequired} class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:border-ink focus:ring-ink" /> diff --git a/frontend/src/lib/components/document/WhoWhenSection.svelte b/frontend/src/lib/components/document/WhoWhenSection.svelte index 71b2feaf..ff90efdf 100644 --- a/frontend/src/lib/components/document/WhoWhenSection.svelte +++ b/frontend/src/lib/components/document/WhoWhenSection.svelte @@ -16,13 +16,17 @@ let { selectedReceivers = $bindable([]), initialDateIso = '', initialLocation = '', - initialSenderName = '' + initialSenderName = '', + suggestedDateIso = '', + suggestedSenderName = '' }: { senderId?: string; selectedReceivers?: Person[]; initialDateIso?: string; initialLocation?: string; initialSenderName?: string; + suggestedDateIso?: string; + suggestedSenderName?: string; } = $props(); let dateDisplay = $state(untrack(() => isoToGerman(initialDateIso))); @@ -37,6 +41,14 @@ function handleDateInput(e: Event) { dateIso = result.iso; dateDirty = true; } + +$effect(() => { + const suggested = suggestedDateIso; + if (suggested && !untrack(() => dateDirty)) { + dateDisplay = isoToGerman(suggested); + dateIso = suggested; + } +});
@@ -90,6 +102,7 @@ function handleDateInput(e: Event) { label={m.form_label_sender()} bind:value={senderId} initialName={initialSenderName} + suggestedName={suggestedSenderName} />
diff --git a/frontend/src/routes/documents/new/+page.svelte b/frontend/src/routes/documents/new/+page.svelte index 6eb3b0c8..ac2d8a6f 100644 --- a/frontend/src/routes/documents/new/+page.svelte +++ b/frontend/src/routes/documents/new/+page.svelte @@ -6,6 +6,7 @@ import WhoWhenSection from '$lib/components/document/WhoWhenSection.svelte'; import DescriptionSection from '$lib/components/document/DescriptionSection.svelte'; import TranscriptionSection from '$lib/components/document/TranscriptionSection.svelte'; import FileSectionNew from './FileSectionNew.svelte'; +import { type FilenameParseResult } from '$lib/utils/filename'; let { data, form } = $props(); @@ -14,6 +15,8 @@ let senderId = $state(untrack(() => data.initialSenderId)); let selectedReceivers: { id: string; firstName: string; lastName: string }[] = $state( untrack(() => data.initialReceivers) ); + +let parsedSuggestion = $state({});
@@ -50,10 +53,16 @@ let selectedReceivers: { id: string; firstName: string; lastName: string }[] = $ bind:senderId={senderId} bind:selectedReceivers={selectedReceivers} initialSenderName={data.initialSenderName} + suggestedDateIso={parsedSuggestion.dateIso ?? ''} + suggestedSenderName={parsedSuggestion.personName ?? ''} + /> + - - + (parsedSuggestion = r)} />
import { m } from '$lib/paraglide/messages.js'; +import { parseFilename, type FilenameParseResult } from '$lib/utils/filename'; + +let { + onfileParsed +}: { + onfileParsed?: (result: FilenameParseResult) => void; +} = $props(); + +function handleFileChange(e: Event) { + const file = (e.target as HTMLInputElement).files?.[0]; + if (file) onfileParsed?.(parseFilename(file.name)); +}
@@ -15,6 +27,7 @@ import { m } from '$lib/paraglide/messages.js'; id="file-upload" type="file" name="file" + onchange={handleFileChange} class="block w-full cursor-pointer text-sm text-ink-2 file:mr-4 file:rounded file:border-0 file:bg-muted -- 2.49.1 From 654e736f8ac686b2af8edd7cf95ca8419771d7e5 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 26 Mar 2026 15:18:12 +0100 Subject: [PATCH 3/8] feat(dropzone): add filename hint showing supported naming pattern Shows a concrete example (2024-03-15_Mueller_Hans.pdf) so users know which filenames will be auto-parsed during bulk upload. Co-Authored-By: Claude Sonnet 4.6 --- frontend/messages/de.json | 1 + frontend/messages/en.json | 1 + frontend/messages/es.json | 1 + frontend/src/routes/DropZone.svelte | 7 +++++-- 4 files changed, 8 insertions(+), 2 deletions(-) diff --git a/frontend/messages/de.json b/frontend/messages/de.json index c441cbf8..6fc63606 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -270,6 +270,7 @@ "pdf_annotations_hide": "Annotierungen verbergen", "upload_drop_hint": "Dateien ablegen oder auswählen", "upload_accepted_types": "PDF, JPEG, PNG, TIFF", + "upload_filename_hint": "Tipp: 2024-03-15_Mueller_Hans.pdf → Datum und Absender werden vorausgefüllt", "upload_success": "{count} Dokument(e) erstellt", "upload_duplicate": "{filename} existiert bereits —", "upload_duplicate_link": "Zum Dokument", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index abd1c189..44be16ab 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -270,6 +270,7 @@ "pdf_annotations_hide": "Hide annotations", "upload_drop_hint": "Drop files or click to select", "upload_accepted_types": "PDF, JPEG, PNG, TIFF", + "upload_filename_hint": "Tip: 2024-03-15_Mueller_Hans.pdf → date and sender pre-filled", "upload_success": "{count} document(s) created", "upload_duplicate": "{filename} already exists —", "upload_duplicate_link": "View document", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 0839bf4e..54438a09 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -270,6 +270,7 @@ "pdf_annotations_hide": "Ocultar anotaciones", "upload_drop_hint": "Soltar archivos o hacer clic para seleccionar", "upload_accepted_types": "PDF, JPEG, PNG, TIFF", + "upload_filename_hint": "Consejo: 2024-03-15_Mueller_Hans.pdf → fecha y remitente prellenados", "upload_success": "{count} documento(s) creado(s)", "upload_duplicate": "{filename} ya existe —", "upload_duplicate_link": "Ver documento", diff --git a/frontend/src/routes/DropZone.svelte b/frontend/src/routes/DropZone.svelte index d845d027..9806d231 100644 --- a/frontend/src/routes/DropZone.svelte +++ b/frontend/src/routes/DropZone.svelte @@ -179,8 +179,11 @@ $effect(() => { {uploadProgress}%
{:else} - {m.upload_drop_hint()} - {m.upload_accepted_types()} +
+ {m.upload_drop_hint()} + {m.upload_accepted_types()} + {m.upload_filename_hint()} +
{/if}
-- 2.49.1 From a302f9656046c95714340eeff915f31156070645 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 26 Mar 2026 15:18:34 +0100 Subject: [PATCH 4/8] feat(quick-upload): generate better title from structured filename titleFromFilename() mirrors the same four patterns as the frontend parseFilename() utility. Dropzone uploads to Mueller_Hans_19650312.pdf now land with title "Hans Mueller (12.03.1965)" instead of the raw stripped filename. Co-Authored-By: Claude Sonnet 4.6 --- .../service/DocumentService.java | 53 ++++++++++++++++++- .../service/DocumentServiceTest.java | 36 +++++++++++++ 2 files changed, 88 insertions(+), 1 deletion(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java index 95cd0921..7d08444a 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java @@ -65,7 +65,7 @@ public class DocumentService { // New uploads from the drop zone always start as incomplete document = Document.builder() .originalFilename(originalFilename) - .title(stripExtension(originalFilename)) + .title(titleFromFilename(originalFilename)) .status(DocumentStatus.UPLOADED) .metadataComplete(false) .build(); @@ -356,6 +356,57 @@ public class DocumentService { return dot > 0 ? filename.substring(0, dot) : filename; } + /** + * Derives a human-readable title from a structured filename. + * Supports patterns (full match only): + * YYYY-MM-DD_Lastname_Firstname.ext + * YYYYMMDD_Lastname_Firstname.ext + * Lastname_Firstname_YYYY-MM-DD.ext + * Lastname_Firstname_YYYYMMDD.ext + * Falls back to stripExtension for unrecognised names. + */ + private static final java.util.regex.Pattern FN_DATE_ISO_NAME = + java.util.regex.Pattern.compile("^(\\d{4}-\\d{2}-\\d{2})_(\\p{L}+)_(\\p{L}+)\\.[^.]+$"); + private static final java.util.regex.Pattern FN_DATE_COMPACT_NAME = + java.util.regex.Pattern.compile("^(\\d{8})_(\\p{L}+)_(\\p{L}+)\\.[^.]+$"); + private static final java.util.regex.Pattern FN_NAME_DATE_ISO = + java.util.regex.Pattern.compile("^(\\p{L}+)_(\\p{L}+)_(\\d{4}-\\d{2}-\\d{2})\\.[^.]+$"); + private static final java.util.regex.Pattern FN_NAME_DATE_COMPACT = + java.util.regex.Pattern.compile("^(\\p{L}+)_(\\p{L}+)_(\\d{8})\\.[^.]+$"); + + static String titleFromFilename(String filename) { + if (filename == null) return null; + java.util.regex.Matcher m; + String dateIso, lastName, firstName; + + if ((m = FN_DATE_ISO_NAME.matcher(filename)).matches()) { + dateIso = m.group(1); + lastName = m.group(2); + firstName = m.group(3); + } else if ((m = FN_DATE_COMPACT_NAME.matcher(filename)).matches()) { + String compact = m.group(1); + dateIso = compact.substring(0, 4) + "-" + compact.substring(4, 6) + "-" + compact.substring(6, 8); + lastName = m.group(2); + firstName = m.group(3); + } else if ((m = FN_NAME_DATE_ISO.matcher(filename)).matches()) { + lastName = m.group(1); + firstName = m.group(2); + dateIso = m.group(3); + } else if ((m = FN_NAME_DATE_COMPACT.matcher(filename)).matches()) { + lastName = m.group(1); + firstName = m.group(2); + String compact = m.group(3); + dateIso = compact.substring(0, 4) + "-" + compact.substring(4, 6) + "-" + compact.substring(6, 8); + } else { + return stripExtension(filename); + } + + // Format date as DD.MM.YYYY for the title + LocalDate date = LocalDate.parse(dateIso); + String dateDisplay = String.format("%02d.%02d.%d", date.getDayOfMonth(), date.getMonthValue(), date.getYear()); + return firstName + " " + lastName + " (" + dateDisplay + ")"; + } + private static String sha256Hex(byte[] bytes) { try { MessageDigest digest = MessageDigest.getInstance("SHA-256"); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java index 195810ab..ff87981d 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java @@ -518,4 +518,40 @@ class DocumentServiceTest { assertThat(count).isEqualTo(2); } + + // ─── titleFromFilename ──────────────────────────────────────────────────── + + @Test + void titleFromFilename_dateIso_name() { + assertThat(DocumentService.titleFromFilename("1965-03-12_Mueller_Hans.pdf")) + .isEqualTo("Hans Mueller (12.03.1965)"); + } + + @Test + void titleFromFilename_dateCompact_name() { + assertThat(DocumentService.titleFromFilename("19650312_Mueller_Hans.pdf")) + .isEqualTo("Hans Mueller (12.03.1965)"); + } + + @Test + void titleFromFilename_name_dateIso() { + assertThat(DocumentService.titleFromFilename("Mueller_Hans_1965-03-12.pdf")) + .isEqualTo("Hans Mueller (12.03.1965)"); + } + + @Test + void titleFromFilename_name_dateCompact() { + assertThat(DocumentService.titleFromFilename("Mueller_Hans_19650312.pdf")) + .isEqualTo("Hans Mueller (12.03.1965)"); + } + + @Test + void titleFromFilename_fallsBackToStripExtension() { + assertThat(DocumentService.titleFromFilename("scan_001.pdf")).isEqualTo("scan_001"); + } + + @Test + void titleFromFilename_null_returnsNull() { + assertThat(DocumentService.titleFromFilename(null)).isNull(); + } } -- 2.49.1 From f0940524e7d0ac1fd0d5fb522c3ab9a60b29f163 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 26 Mar 2026 15:33:21 +0100 Subject: [PATCH 5/8] feat(filename): support compound last names like de Gruyter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the four fixed regexes with a split-based algorithm: - first segment = date → last segment = firstName, rest = lastName parts - last segment = date → second-to-last = firstName, rest = lastName parts 18881025_de_Gruyter_Walter.pdf now correctly yields "Walter de Gruyter". Simple two-segment names behave identically to before. Co-Authored-By: Claude Sonnet 4.6 --- .../service/DocumentService.java | 89 +++++++++++-------- .../service/DocumentServiceTest.java | 12 +++ frontend/src/lib/utils/filename.spec.ts | 54 +++++++---- frontend/src/lib/utils/filename.ts | 89 ++++++++++++------- 4 files changed, 157 insertions(+), 87 deletions(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java index 7d08444a..dab03a48 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java @@ -358,55 +358,70 @@ public class DocumentService { /** * Derives a human-readable title from a structured filename. - * Supports patterns (full match only): - * YYYY-MM-DD_Lastname_Firstname.ext - * YYYYMMDD_Lastname_Firstname.ext - * Lastname_Firstname_YYYY-MM-DD.ext - * Lastname_Firstname_YYYYMMDD.ext - * Falls back to stripExtension for unrecognised names. + * + * Algorithm: split stem on "_", identify the date token (first or last segment), + * treat the outermost remaining segment as firstName, rest as lastName parts. + * Compound last names (e.g. "de_Gruyter") are handled naturally. + * Falls back to stripExtension for unrecognised filenames. + * + * Examples: + * 18881025_de_Gruyter_Walter.pdf → "Walter de Gruyter (25.10.1888)" + * 1965-03-12_Mueller_Hans.pdf → "Hans Mueller (12.03.1965)" + * Mueller_Hans_19650312.pdf → "Hans Mueller (12.03.1965)" */ - private static final java.util.regex.Pattern FN_DATE_ISO_NAME = - java.util.regex.Pattern.compile("^(\\d{4}-\\d{2}-\\d{2})_(\\p{L}+)_(\\p{L}+)\\.[^.]+$"); - private static final java.util.regex.Pattern FN_DATE_COMPACT_NAME = - java.util.regex.Pattern.compile("^(\\d{8})_(\\p{L}+)_(\\p{L}+)\\.[^.]+$"); - private static final java.util.regex.Pattern FN_NAME_DATE_ISO = - java.util.regex.Pattern.compile("^(\\p{L}+)_(\\p{L}+)_(\\d{4}-\\d{2}-\\d{2})\\.[^.]+$"); - private static final java.util.regex.Pattern FN_NAME_DATE_COMPACT = - java.util.regex.Pattern.compile("^(\\p{L}+)_(\\p{L}+)_(\\d{8})\\.[^.]+$"); - static String titleFromFilename(String filename) { if (filename == null) return null; - java.util.regex.Matcher m; - String dateIso, lastName, firstName; - if ((m = FN_DATE_ISO_NAME.matcher(filename)).matches()) { - dateIso = m.group(1); - lastName = m.group(2); - firstName = m.group(3); - } else if ((m = FN_DATE_COMPACT_NAME.matcher(filename)).matches()) { - String compact = m.group(1); - dateIso = compact.substring(0, 4) + "-" + compact.substring(4, 6) + "-" + compact.substring(6, 8); - lastName = m.group(2); - firstName = m.group(3); - } else if ((m = FN_NAME_DATE_ISO.matcher(filename)).matches()) { - lastName = m.group(1); - firstName = m.group(2); - dateIso = m.group(3); - } else if ((m = FN_NAME_DATE_COMPACT.matcher(filename)).matches()) { - lastName = m.group(1); - firstName = m.group(2); - String compact = m.group(3); - dateIso = compact.substring(0, 4) + "-" + compact.substring(4, 6) + "-" + compact.substring(6, 8); + int dot = filename.lastIndexOf('.'); + if (dot < 0) return stripExtension(filename); + String stem = filename.substring(0, dot); + + String[] parts = stem.split("_", -1); + // Minimum: date + at least one lastName segment + firstName + if (parts.length < 3) return stripExtension(filename); + + String dateIso; + String[] nameParts; + + String dateFromFirst = tryParseDate(parts[0]); + if (dateFromFirst != null) { + dateIso = dateFromFirst; + nameParts = Arrays.copyOfRange(parts, 1, parts.length); } else { - return stripExtension(filename); + String dateFromLast = tryParseDate(parts[parts.length - 1]); + if (dateFromLast == null) return stripExtension(filename); + dateIso = dateFromLast; + nameParts = Arrays.copyOfRange(parts, 0, parts.length - 1); } - // Format date as DD.MM.YYYY for the title + if (nameParts.length < 2) return stripExtension(filename); + + for (String p : nameParts) { + if (!p.matches("\\p{L}+")) return stripExtension(filename); + } + + String firstName = nameParts[nameParts.length - 1]; + String lastName = String.join(" ", Arrays.copyOfRange(nameParts, 0, nameParts.length - 1)); + LocalDate date = LocalDate.parse(dateIso); String dateDisplay = String.format("%02d.%02d.%d", date.getDayOfMonth(), date.getMonthValue(), date.getYear()); return firstName + " " + lastName + " (" + dateDisplay + ")"; } + private static String tryParseDate(String s) { + if (s.matches("\\d{4}-\\d{2}-\\d{2}")) { + int m = Integer.parseInt(s.substring(5, 7)); + int d = Integer.parseInt(s.substring(8, 10)); + if (m >= 1 && m <= 12 && d >= 1 && d <= 31) return s; + } else if (s.matches("\\d{8}")) { + int m = Integer.parseInt(s.substring(4, 6)); + int d = Integer.parseInt(s.substring(6, 8)); + if (m >= 1 && m <= 12 && d >= 1 && d <= 31) + return s.substring(0, 4) + "-" + s.substring(4, 6) + "-" + s.substring(6, 8); + } + return null; + } + private static String sha256Hex(byte[] bytes) { try { MessageDigest digest = MessageDigest.getInstance("SHA-256"); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java index ff87981d..5e94216a 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java @@ -545,6 +545,18 @@ class DocumentServiceTest { .isEqualTo("Hans Mueller (12.03.1965)"); } + @Test + void titleFromFilename_compound_lastName_dateFirst() { + assertThat(DocumentService.titleFromFilename("18881025_de_Gruyter_Walter.pdf")) + .isEqualTo("Walter de Gruyter (25.10.1888)"); + } + + @Test + void titleFromFilename_compound_lastName_dateLast() { + assertThat(DocumentService.titleFromFilename("de_Gruyter_Walter_18881025.pdf")) + .isEqualTo("Walter de Gruyter (25.10.1888)"); + } + @Test void titleFromFilename_fallsBackToStripExtension() { assertThat(DocumentService.titleFromFilename("scan_001.pdf")).isEqualTo("scan_001"); diff --git a/frontend/src/lib/utils/filename.spec.ts b/frontend/src/lib/utils/filename.spec.ts index 77c28d6b..297c8a1e 100644 --- a/frontend/src/lib/utils/filename.spec.ts +++ b/frontend/src/lib/utils/filename.spec.ts @@ -2,8 +2,8 @@ import { describe, it, expect } from 'vitest'; import { parseFilename, stripExtension } from './filename'; describe('parseFilename', () => { - describe('YYYY-MM-DD_Lastname_Firstname pattern', () => { - it('extracts date and name', () => { + describe('date-first patterns', () => { + it('YYYY-MM-DD_Lastname_Firstname', () => { expect(parseFilename('1965-03-12_Mueller_Hans.pdf')).toEqual({ dateIso: '1965-03-12', personName: 'Hans Mueller', @@ -11,40 +11,52 @@ describe('parseFilename', () => { }); }); + it('YYYYMMDD_Lastname_Firstname', () => { + expect(parseFilename('19650312_Mueller_Hans.pdf')).toEqual({ + dateIso: '1965-03-12', + personName: 'Hans Mueller', + suggestedTitle: 'Hans Mueller (12.03.1965)' + }); + }); + + it('YYYYMMDD_compound_lastname_Firstname', () => { + expect(parseFilename('18881025_de_Gruyter_Walter.pdf')).toEqual({ + dateIso: '1888-10-25', + personName: 'Walter de Gruyter', + suggestedTitle: 'Walter de Gruyter (25.10.1888)' + }); + }); + it('handles umlauts in names', () => { const result = parseFilename('2024-01-15_Müller_Jürgen.pdf'); expect(result.personName).toBe('Jürgen Müller'); }); }); - describe('YYYYMMDD_Lastname_Firstname pattern', () => { - it('extracts date and name', () => { - expect(parseFilename('19650312_Mueller_Hans.pdf')).toEqual({ - dateIso: '1965-03-12', - personName: 'Hans Mueller', - suggestedTitle: 'Hans Mueller (12.03.1965)' - }); - }); - }); - - describe('Lastname_Firstname_YYYY-MM-DD pattern', () => { - it('extracts date and name', () => { + describe('date-last patterns', () => { + it('Lastname_Firstname_YYYY-MM-DD', () => { expect(parseFilename('Mueller_Hans_1965-03-12.pdf')).toEqual({ dateIso: '1965-03-12', personName: 'Hans Mueller', suggestedTitle: 'Hans Mueller (12.03.1965)' }); }); - }); - describe('Lastname_Firstname_YYYYMMDD pattern', () => { - it('extracts date and name', () => { + it('Lastname_Firstname_YYYYMMDD', () => { expect(parseFilename('Mueller_Hans_19650312.pdf')).toEqual({ dateIso: '1965-03-12', personName: 'Hans Mueller', suggestedTitle: 'Hans Mueller (12.03.1965)' }); }); + + it('compound_lastname_Firstname_YYYYMMDD', () => { + expect(parseFilename('de_Gruyter_Walter_18881025.pdf')).toEqual({ + dateIso: '1888-10-25', + personName: 'Walter de Gruyter', + suggestedTitle: 'Walter de Gruyter (25.10.1888)' + }); + }); }); describe('non-matching filenames', () => { @@ -52,7 +64,7 @@ describe('parseFilename', () => { expect(parseFilename('1965-03-12.pdf')).toEqual({}); }); - it('returns empty for name-only filename', () => { + it('returns empty for two segments with no date', () => { expect(parseFilename('Mueller_Hans.pdf')).toEqual({}); }); @@ -60,13 +72,17 @@ describe('parseFilename', () => { expect(parseFilename('scan_001.pdf')).toEqual({}); }); - it('returns empty for three name segments without date', () => { + it('returns empty for three name segments with no date', () => { expect(parseFilename('Mueller_Hans_Juergen.pdf')).toEqual({}); }); it('returns empty for filename without extension', () => { expect(parseFilename('1965-03-12_Mueller_Hans')).toEqual({}); }); + + it('rejects implausible date (month 13)', () => { + expect(parseFilename('19651345_Mueller_Hans.pdf')).toEqual({}); + }); }); }); diff --git a/frontend/src/lib/utils/filename.ts b/frontend/src/lib/utils/filename.ts index 7df5213c..101fdd1c 100644 --- a/frontend/src/lib/utils/filename.ts +++ b/frontend/src/lib/utils/filename.ts @@ -9,46 +9,73 @@ export interface FilenameParseResult { suggestedTitle?: string; } -// Full-match patterns only. Name segments use Unicode letters (\p{L}) to cover umlauts etc. -// Order: date_lastname_firstname -const P_DATE_ISO_NAME = /^(\d{4}-\d{2}-\d{2})_(\p{L}+)_(\p{L}+)\.[^.]+$/u; -const P_DATE_COMPACT_NAME = /^(\d{8})_(\p{L}+)_(\p{L}+)\.[^.]+$/u; -// Order: lastname_firstname_date -const P_NAME_DATE_ISO = /^(\p{L}+)_(\p{L}+)_(\d{4}-\d{2}-\d{2})\.[^.]+$/u; -const P_NAME_DATE_COMPACT = /^(\p{L}+)_(\p{L}+)_(\d{8})\.[^.]+$/u; - -function compactToIso(compact: string): string { - return `${compact.slice(0, 4)}-${compact.slice(4, 6)}-${compact.slice(6, 8)}`; +// A date token is either YYYY-MM-DD or YYYYMMDD with a plausible month/day range. +function tryParseDate(s: string): string | undefined { + if (/^\d{4}-\d{2}-\d{2}$/.test(s)) { + const m = parseInt(s.slice(5, 7)); + const d = parseInt(s.slice(8, 10)); + if (m >= 1 && m <= 12 && d >= 1 && d <= 31) return s; + } else if (/^\d{8}$/.test(s)) { + const m = parseInt(s.slice(4, 6)); + const d = parseInt(s.slice(6, 8)); + if (m >= 1 && m <= 12 && d >= 1 && d <= 31) + return `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}`; + } + return undefined; } +const NAME_PART = /^\p{L}+$/u; + +/** + * Parses a structured filename and extracts a date and person name. + * + * Supported conventions (date-first or date-last, compound last names supported): + * YYYY-MM-DD_Lastname_Firstname.ext + * YYYYMMDD_Lastname_Firstname.ext + * YYYYMMDD_de_Gruyter_Walter.ext ← compound last name: lastName="de Gruyter" + * Lastname_Firstname_YYYY-MM-DD.ext + * Lastname_Firstname_YYYYMMDD.ext + * de_Gruyter_Walter_YYYYMMDD.ext ← compound last name: lastName="de Gruyter" + * + * Algorithm: split on "_", identify the date token (first or last segment), + * treat the outermost remaining segment as firstName, rest as lastName parts. + * Returns {} for anything that doesn't match cleanly. + */ export function parseFilename(filename: string): FilenameParseResult { + const dot = filename.lastIndexOf('.'); + if (dot < 0) return {}; // no extension — not a real file + const stem = filename.slice(0, dot); + const parts = stem.split('_'); + + // Minimum: date + at least one lastName segment + firstName = 3 parts + if (parts.length < 3) return {}; + let dateIso: string; - let lastName: string; - let firstName: string; + let nameParts: string[]; - let m: RegExpMatchArray | null; - - if ((m = P_DATE_ISO_NAME.exec(filename))) { - [, dateIso, lastName, firstName] = m; - } else if ((m = P_DATE_COMPACT_NAME.exec(filename))) { - dateIso = compactToIso(m[1]); - lastName = m[2]; - firstName = m[3]; - } else if ((m = P_NAME_DATE_ISO.exec(filename))) { - lastName = m[1]; - firstName = m[2]; - dateIso = m[3]; - } else if ((m = P_NAME_DATE_COMPACT.exec(filename))) { - lastName = m[1]; - firstName = m[2]; - dateIso = compactToIso(m[3]); + const dateFromFirst = tryParseDate(parts[0]); + if (dateFromFirst) { + dateIso = dateFromFirst; + nameParts = parts.slice(1); } else { - return {}; + const dateFromLast = tryParseDate(parts[parts.length - 1]); + if (!dateFromLast) return {}; + dateIso = dateFromLast; + nameParts = parts.slice(0, -1); } + // Need at least lastName + firstName after removing the date + if (nameParts.length < 2) return {}; + + // All name segments must be pure letters (covers umlauts via \p{L}) + if (!nameParts.every((p) => NAME_PART.test(p))) return {}; + + const firstName = nameParts[nameParts.length - 1]; + const lastName = nameParts.slice(0, -1).join(' '); const personName = `${firstName} ${lastName}`; - const suggestedTitle = `${personName} (${isoToGerman(dateIso!)})`; - return { dateIso: dateIso!, personName, suggestedTitle }; + const suggestedTitle = `${personName} (${isoToGerman(dateIso)})`; + + return { dateIso, personName, suggestedTitle }; } export function stripExtension(filename: string): string { -- 2.49.1 From 99e3163c0e13893fc16376586a995f468ed342d9 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 26 Mar 2026 15:43:39 +0100 Subject: [PATCH 6/8] feat(quick-upload): pre-fill date and sender from structured filename MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit storeDocument() now uses the ParsedFilename record to also set documentDate and sender on new quick-uploads. Sender lookup is an exact case-insensitive first+last name match — no new persons are created. Unmatched filenames behave as before. Co-Authored-By: Claude Sonnet 4.6 --- .../repository/PersonRepository.java | 3 ++ .../service/DocumentService.java | 53 ++++++++++++------- .../familienarchiv/service/PersonService.java | 5 ++ .../service/DocumentServiceTest.java | 48 +++++++++++++++++ 4 files changed, 90 insertions(+), 19 deletions(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/repository/PersonRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/repository/PersonRepository.java index 2fef46d3..3c1411b1 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/repository/PersonRepository.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/repository/PersonRepository.java @@ -28,6 +28,9 @@ public interface PersonRepository extends JpaRepository { // Lookup by full alias string, used during ODS mass import Optional findByAliasIgnoreCase(String alias); + // Exact first+last name match, used for filename-based sender lookup + Optional findByFirstNameIgnoreCaseAndLastNameIgnoreCase(String firstName, String lastName); + // --- Correspondent queries --- @Query(value = """ diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java index dab03a48..bb379194 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java @@ -63,9 +63,15 @@ public class DocumentService { document = existingDoc.get(); } else { // New uploads from the drop zone always start as incomplete + ParsedFilename parsed = parseFilenameData(originalFilename); + Person sender = (parsed != null) + ? personService.findByName(parsed.firstName(), parsed.lastName()).orElse(null) + : null; document = Document.builder() .originalFilename(originalFilename) - .title(titleFromFilename(originalFilename)) + .title(parsed != null ? parsed.title() : stripExtension(originalFilename)) + .documentDate(parsed != null ? parsed.date() : null) + .sender(sender) .status(DocumentStatus.UPLOADED) .metadataComplete(false) .build(); @@ -356,29 +362,35 @@ public class DocumentService { return dot > 0 ? filename.substring(0, dot) : filename; } + private record ParsedFilename(LocalDate date, String firstName, String lastName) { + String title() { + String dateDisplay = String.format("%02d.%02d.%d", + date.getDayOfMonth(), date.getMonthValue(), date.getYear()); + return firstName + " " + lastName + " (" + dateDisplay + ")"; + } + } + /** - * Derives a human-readable title from a structured filename. + * Parses a structured filename into its date and name components. * * Algorithm: split stem on "_", identify the date token (first or last segment), * treat the outermost remaining segment as firstName, rest as lastName parts. - * Compound last names (e.g. "de_Gruyter") are handled naturally. - * Falls back to stripExtension for unrecognised filenames. + * Compound last names (e.g. "de_Gruyter") are supported naturally. + * Returns null for unrecognised filenames. * * Examples: - * 18881025_de_Gruyter_Walter.pdf → "Walter de Gruyter (25.10.1888)" - * 1965-03-12_Mueller_Hans.pdf → "Hans Mueller (12.03.1965)" - * Mueller_Hans_19650312.pdf → "Hans Mueller (12.03.1965)" + * 18881025_de_Gruyter_Walter.pdf → date=1888-10-25, firstName=Walter, lastName=de Gruyter + * 1965-03-12_Mueller_Hans.pdf → date=1965-03-12, firstName=Hans, lastName=Mueller + * Mueller_Hans_19650312.pdf → date=1965-03-12, firstName=Hans, lastName=Mueller */ - static String titleFromFilename(String filename) { + private static ParsedFilename parseFilenameData(String filename) { if (filename == null) return null; - int dot = filename.lastIndexOf('.'); - if (dot < 0) return stripExtension(filename); + if (dot < 0) return null; String stem = filename.substring(0, dot); String[] parts = stem.split("_", -1); - // Minimum: date + at least one lastName segment + firstName - if (parts.length < 3) return stripExtension(filename); + if (parts.length < 3) return null; String dateIso; String[] nameParts; @@ -389,23 +401,26 @@ public class DocumentService { nameParts = Arrays.copyOfRange(parts, 1, parts.length); } else { String dateFromLast = tryParseDate(parts[parts.length - 1]); - if (dateFromLast == null) return stripExtension(filename); + if (dateFromLast == null) return null; dateIso = dateFromLast; nameParts = Arrays.copyOfRange(parts, 0, parts.length - 1); } - if (nameParts.length < 2) return stripExtension(filename); - + if (nameParts.length < 2) return null; for (String p : nameParts) { - if (!p.matches("\\p{L}+")) return stripExtension(filename); + if (!p.matches("\\p{L}+")) return null; } String firstName = nameParts[nameParts.length - 1]; String lastName = String.join(" ", Arrays.copyOfRange(nameParts, 0, nameParts.length - 1)); + return new ParsedFilename(LocalDate.parse(dateIso), firstName, lastName); + } - LocalDate date = LocalDate.parse(dateIso); - String dateDisplay = String.format("%02d.%02d.%d", date.getDayOfMonth(), date.getMonthValue(), date.getYear()); - return firstName + " " + lastName + " (" + dateDisplay + ")"; + // Used by tests and as a public utility; delegates to parseFilenameData. + static String titleFromFilename(String filename) { + if (filename == null) return null; + ParsedFilename parsed = parseFilenameData(filename); + return parsed != null ? parsed.title() : stripExtension(filename); } private static String tryParseDate(String s) { diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java index a3b56da1..8ac9a3bd 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java @@ -1,6 +1,7 @@ package org.raddatz.familienarchiv.service; import java.util.List; +import java.util.Optional; import java.util.UUID; import org.raddatz.familienarchiv.dto.PersonUpdateDTO; @@ -42,6 +43,10 @@ public class PersonService { return personRepository.findAllById(ids); } + public Optional findByName(String firstName, String lastName) { + return personRepository.findByFirstNameIgnoreCaseAndLastNameIgnoreCase(firstName, lastName); + } + @Transactional public Person findOrCreateByAlias(String rawName) { String alias = rawName.trim(); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java index 5e94216a..c4a1c6d2 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java @@ -10,6 +10,7 @@ import org.raddatz.familienarchiv.dto.DocumentUpdateDTO; import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.model.Document; import org.raddatz.familienarchiv.model.DocumentStatus; +import org.raddatz.familienarchiv.model.Person; import org.raddatz.familienarchiv.model.Tag; import org.raddatz.familienarchiv.repository.DocumentRepository; import org.springframework.data.domain.Sort; @@ -419,6 +420,53 @@ class DocumentServiceTest { assertThat(existing.isMetadataComplete()).isTrue(); } + @Test + void storeDocument_parsesDateFromFilename_forNewDocument() throws Exception { + MockMultipartFile file = new MockMultipartFile("file", "19650312_Mueller_Hans.pdf", "application/pdf", new byte[]{1}); + when(documentRepository.findFirstByOriginalFilename(any())).thenReturn(Optional.empty()); + when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("path", "hash")); + when(personService.findByName(any(), any())).thenReturn(Optional.empty()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Document.class); + documentService.storeDocument(file); + + verify(documentRepository).save(captor.capture()); + assertThat(captor.getValue().getDocumentDate()).isEqualTo(java.time.LocalDate.of(1965, 3, 12)); + assertThat(captor.getValue().getTitle()).isEqualTo("Hans Mueller (12.03.1965)"); + } + + @Test + void storeDocument_setsSender_whenPersonExistsForParsedName() throws Exception { + MockMultipartFile file = new MockMultipartFile("file", "18881025_de_Gruyter_Walter.pdf", "application/pdf", new byte[]{1}); + Person walter = Person.builder().id(UUID.randomUUID()).firstName("Walter").lastName("de Gruyter").build(); + when(documentRepository.findFirstByOriginalFilename(any())).thenReturn(Optional.empty()); + when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("path", "hash")); + when(personService.findByName("Walter", "de Gruyter")).thenReturn(Optional.of(walter)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Document.class); + documentService.storeDocument(file); + + verify(documentRepository).save(captor.capture()); + assertThat(captor.getValue().getSender()).isEqualTo(walter); + } + + @Test + void storeDocument_leavesSenderNull_whenPersonNotFound() throws Exception { + MockMultipartFile file = new MockMultipartFile("file", "19650312_Mueller_Hans.pdf", "application/pdf", new byte[]{1}); + when(documentRepository.findFirstByOriginalFilename(any())).thenReturn(Optional.empty()); + when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("path", "hash")); + when(personService.findByName(any(), any())).thenReturn(Optional.empty()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Document.class); + documentService.storeDocument(file); + + verify(documentRepository).save(captor.capture()); + assertThat(captor.getValue().getSender()).isNull(); + } + // ─── createDocument metadataComplete ───────────────────────────────────── @Test -- 2.49.1 From 648bdffe4fb128468059cd0eb45a14bdf5e6a7d6 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 26 Mar 2026 15:51:15 +0100 Subject: [PATCH 7/8] fix(enrich): restore fixed-position layout and done icon Re-applies the scroll fix from 0d3c557 which was missing from this branch: - measure header height at mount, use it as top offset instead of hardcoded 68px - fix done page icon to Check-Double-LG Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/routes/enrich/[id]/+page.svelte | 9 +++++++-- frontend/src/routes/enrich/done/+page.svelte | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/frontend/src/routes/enrich/[id]/+page.svelte b/frontend/src/routes/enrich/[id]/+page.svelte index 5bd896e2..0e08d185 100644 --- a/frontend/src/routes/enrich/[id]/+page.svelte +++ b/frontend/src/routes/enrich/[id]/+page.svelte @@ -1,6 +1,6 @@