feat: suggest date, sender and title from structured filename (#69) #78
@@ -28,6 +28,9 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
||||
// Lookup by full alias string, used during ODS mass import
|
||||
Optional<Person> findByAliasIgnoreCase(String alias);
|
||||
|
||||
// Exact first+last name match, used for filename-based sender lookup
|
||||
Optional<Person> findByFirstNameIgnoreCaseAndLastNameIgnoreCase(String firstName, String lastName);
|
||||
|
||||
// --- Correspondent queries ---
|
||||
|
||||
@Query(value = """
|
||||
|
||||
@@ -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(stripExtension(originalFilename))
|
||||
.title(parsed != null ? parsed.title() : stripExtension(originalFilename))
|
||||
.documentDate(parsed != null ? parsed.date() : null)
|
||||
.sender(sender)
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.metadataComplete(false)
|
||||
.build();
|
||||
@@ -356,6 +362,81 @@ 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 + ")";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 supported naturally.
|
||||
* Returns null for unrecognised filenames.
|
||||
*
|
||||
* Examples:
|
||||
* 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
|
||||
*/
|
||||
private static ParsedFilename parseFilenameData(String filename) {
|
||||
if (filename == null) return null;
|
||||
int dot = filename.lastIndexOf('.');
|
||||
if (dot < 0) return null;
|
||||
String stem = filename.substring(0, dot);
|
||||
|
||||
String[] parts = stem.split("_", -1);
|
||||
if (parts.length < 3) return null;
|
||||
|
||||
String dateIso;
|
||||
String[] nameParts;
|
||||
|
||||
String dateFromFirst = tryParseDate(parts[0]);
|
||||
if (dateFromFirst != null) {
|
||||
dateIso = dateFromFirst;
|
||||
nameParts = Arrays.copyOfRange(parts, 1, parts.length);
|
||||
} else {
|
||||
String dateFromLast = tryParseDate(parts[parts.length - 1]);
|
||||
if (dateFromLast == null) return null;
|
||||
dateIso = dateFromLast;
|
||||
nameParts = Arrays.copyOfRange(parts, 0, parts.length - 1);
|
||||
}
|
||||
|
||||
if (nameParts.length < 2) return null;
|
||||
for (String p : nameParts) {
|
||||
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);
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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");
|
||||
|
||||
@@ -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<Person> findByName(String firstName, String lastName) {
|
||||
return personRepository.findByFirstNameIgnoreCaseAndLastNameIgnoreCase(firstName, lastName);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Person findOrCreateByAlias(String rawName) {
|
||||
String alias = rawName.trim();
|
||||
|
||||
@@ -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<Document> 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<Document> 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<Document> captor = ArgumentCaptor.forClass(Document.class);
|
||||
documentService.storeDocument(file);
|
||||
|
||||
verify(documentRepository).save(captor.capture());
|
||||
assertThat(captor.getValue().getSender()).isNull();
|
||||
}
|
||||
|
||||
// ─── createDocument metadataComplete ─────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@@ -518,4 +566,52 @@ 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_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");
|
||||
}
|
||||
|
||||
@Test
|
||||
void titleFromFilename_null_returnsNull() {
|
||||
assertThat(DocumentService.titleFromFilename(null)).isNull();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import TagInput from '$lib/components/TagInput.svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
@@ -7,14 +8,26 @@ let {
|
||||
initialTitle = '',
|
||||
initialDocumentLocation = '',
|
||||
initialSummary = '',
|
||||
titleRequired = false
|
||||
titleRequired = false,
|
||||
suggestedTitle = ''
|
||||
}: {
|
||||
tags?: string[];
|
||||
initialTitle?: string;
|
||||
initialDocumentLocation?: string;
|
||||
initialSummary?: string;
|
||||
titleRequired?: boolean;
|
||||
suggestedTitle?: string;
|
||||
} = $props();
|
||||
|
||||
let titleValue = $state(untrack(() => initialTitle));
|
||||
let titleDirty = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
const suggested = suggestedTitle;
|
||||
if (suggested && !untrack(() => titleDirty)) {
|
||||
titleValue = suggested;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -16,13 +16,17 @@ let {
|
||||
selectedReceivers = $bindable<Person[]>([]),
|
||||
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;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||
@@ -90,6 +102,7 @@ function handleDateInput(e: Event) {
|
||||
label={m.form_label_sender()}
|
||||
bind:value={senderId}
|
||||
initialName={initialSenderName}
|
||||
suggestedName={suggestedSenderName}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
101
frontend/src/lib/utils/filename.spec.ts
Normal file
101
frontend/src/lib/utils/filename.spec.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseFilename, stripExtension } from './filename';
|
||||
|
||||
describe('parseFilename', () => {
|
||||
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',
|
||||
suggestedTitle: 'Hans Mueller (12.03.1965)'
|
||||
});
|
||||
});
|
||||
|
||||
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('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)'
|
||||
});
|
||||
});
|
||||
|
||||
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', () => {
|
||||
it('returns empty for date-only filename', () => {
|
||||
expect(parseFilename('1965-03-12.pdf')).toEqual({});
|
||||
});
|
||||
|
||||
it('returns empty for two segments with no date', () => {
|
||||
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 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({});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
83
frontend/src/lib/utils/filename.ts
Normal file
83
frontend/src/lib/utils/filename.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
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;
|
||||
}
|
||||
|
||||
// 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 nameParts: string[];
|
||||
|
||||
const dateFromFirst = tryParseDate(parts[0]);
|
||||
if (dateFromFirst) {
|
||||
dateIso = dateFromFirst;
|
||||
nameParts = parts.slice(1);
|
||||
} else {
|
||||
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, personName, suggestedTitle };
|
||||
}
|
||||
|
||||
export function stripExtension(filename: string): string {
|
||||
return filename.replace(/\.[^/.]+$/, '');
|
||||
}
|
||||
@@ -179,8 +179,11 @@ $effect(() => {
|
||||
<span class="font-sans text-xs text-ink-3">{uploadProgress}%</span>
|
||||
</div>
|
||||
{:else}
|
||||
<span class="font-sans font-medium">{m.upload_drop_hint()}</span>
|
||||
<span class="font-sans text-xs text-ink-3">{m.upload_accepted_types()}</span>
|
||||
<div class="flex flex-col items-center gap-0.5">
|
||||
<span class="font-sans font-medium">{m.upload_drop_hint()}</span>
|
||||
<span class="font-sans text-xs text-ink-3">{m.upload_accepted_types()}</span>
|
||||
<span class="font-sans text-xs text-ink-3/70 italic">{m.upload_filename_hint()}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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<FilenameParseResult>({});
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-4xl px-4 py-8">
|
||||
@@ -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 ?? ''}
|
||||
/>
|
||||
<DescriptionSection
|
||||
bind:tags={tags}
|
||||
titleRequired={true}
|
||||
suggestedTitle={parsedSuggestion.suggestedTitle ?? ''}
|
||||
/>
|
||||
<DescriptionSection bind:tags={tags} titleRequired={true} />
|
||||
<TranscriptionSection />
|
||||
<FileSectionNew />
|
||||
<FileSectionNew onfileParsed={(r) => (parsedSuggestion = r)} />
|
||||
|
||||
<!-- Sticky Save Bar -->
|
||||
<div
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
<script lang="ts">
|
||||
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));
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user