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 bb379194..4b332b51 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java @@ -108,9 +108,13 @@ public class DocumentService { || (dto.getReceiverIds() != null && !dto.getReceiverIds().isEmpty()); } + String titleToUse = (dto.getTitle() != null && !dto.getTitle().isBlank()) + ? dto.getTitle() + : titleFromFilename(filename); + Document doc = Document.builder() .originalFilename(filename) - .title(dto.getTitle()) + .title(titleToUse) .documentDate(dto.getDocumentDate()) .location(dto.getLocation()) .documentLocation(dto.getDocumentLocation()) 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 c4a1c6d2..c02fb358 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java @@ -467,6 +467,62 @@ class DocumentServiceTest { assertThat(captor.getValue().getSender()).isNull(); } + // ─── createDocument title fallback ──────────────────────────────────────── + + @Test + void createDocument_usesTitleFromFilename_whenDtoTitleIsNull() throws Exception { + DocumentUpdateDTO dto = new DocumentUpdateDTO(); + // dto.title is null + MockMultipartFile file = new MockMultipartFile("file", "Brief_1965.pdf", "application/pdf", new byte[]{1}); + Document saved = Document.builder().id(UUID.randomUUID()).title("Brief_1965") + .originalFilename("Brief_1965.pdf").status(DocumentStatus.PLACEHOLDER).build(); + when(documentRepository.save(any())).thenReturn(saved); + when(documentRepository.findById(any())).thenReturn(Optional.of(saved)); + when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("path", "hash")); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Document.class); + documentService.createDocument(dto, file); + + verify(documentRepository, atLeastOnce()).save(captor.capture()); + assertThat(captor.getAllValues().get(0).getTitle()).isEqualTo("Brief_1965"); + } + + @Test + void createDocument_usesTitleFromFilename_whenDtoTitleIsBlank() throws Exception { + DocumentUpdateDTO dto = new DocumentUpdateDTO(); + dto.setTitle(" "); + MockMultipartFile file = new MockMultipartFile("file", "Rechnung_1980.pdf", "application/pdf", new byte[]{1}); + Document saved = Document.builder().id(UUID.randomUUID()).title("Rechnung_1980") + .originalFilename("Rechnung_1980.pdf").status(DocumentStatus.PLACEHOLDER).build(); + when(documentRepository.save(any())).thenReturn(saved); + when(documentRepository.findById(any())).thenReturn(Optional.of(saved)); + when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("path", "hash")); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Document.class); + documentService.createDocument(dto, file); + + verify(documentRepository, atLeastOnce()).save(captor.capture()); + assertThat(captor.getAllValues().get(0).getTitle()).isEqualTo("Rechnung_1980"); + } + + @Test + void createDocument_keepsDtoTitle_whenProvided() throws Exception { + DocumentUpdateDTO dto = new DocumentUpdateDTO(); + dto.setTitle("Mein Titel"); + MockMultipartFile file = new MockMultipartFile("file", "scan.pdf", "application/pdf", new byte[]{1}); + Document saved = Document.builder().id(UUID.randomUUID()).title("Mein Titel") + .originalFilename("scan.pdf").status(DocumentStatus.PLACEHOLDER).build(); + when(documentRepository.save(any())).thenReturn(saved); + when(documentRepository.findById(any())).thenReturn(Optional.of(saved)); + when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("path", "hash")); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Document.class); + documentService.createDocument(dto, file); + + verify(documentRepository, atLeastOnce()).save(captor.capture()); + assertThat(captor.getAllValues().get(0).getTitle()).isEqualTo("Mein Titel"); + } + // ─── createDocument metadataComplete ───────────────────────────────────── @Test diff --git a/frontend/e2e/documents.spec.ts b/frontend/e2e/documents.spec.ts index 8ddac2b1..3f122c29 100644 --- a/frontend/e2e/documents.spec.ts +++ b/frontend/e2e/documents.spec.ts @@ -77,12 +77,49 @@ test.describe('Document detail', () => { }); test.describe('New document', () => { - test('renders the upload form', async ({ page }) => { + test('renders the upload form with file input first', async ({ page }) => { await page.goto('/documents/new'); + await page.waitForSelector('[data-hydrated]'); await expect(page.getByRole('heading', { name: /Neues Dokument/i })).toBeVisible(); - await expect(page.getByLabel('Titel')).toBeVisible(); + // File input comes before the title field in DOM order + const fileInput = page.locator('input[type="file"]'); + const titleInput = page.getByLabel('Titel'); + await expect(fileInput).toBeVisible(); + await expect(titleInput).toBeVisible(); + const fileBox = await fileInput.boundingBox(); + const titleBox = await titleInput.boundingBox(); + expect(fileBox!.y).toBeLessThan(titleBox!.y); await page.screenshot({ path: 'test-results/e2e/document-new.png' }); }); + + test('title field is pre-filled from filename when a file is selected', async ({ page }) => { + await page.goto('/documents/new'); + await page.waitForSelector('[data-hydrated]'); + const PDF_FIXTURE = path.resolve(__dirname, 'fixtures/minimal.pdf'); + const fileInput = page.locator('input[type="file"]'); + await fileInput.setInputFiles({ + name: 'Brief_1965.pdf', + mimeType: 'application/pdf', + buffer: fs.readFileSync(PDF_FIXTURE) + }); + await expect(page.getByLabel('Titel')).toHaveValue('Brief_1965'); + await page.screenshot({ path: 'test-results/e2e/document-new-filename-prefill.png' }); + }); + + test('typed title is not overwritten when a file is selected', async ({ page }) => { + await page.goto('/documents/new'); + await page.waitForSelector('[data-hydrated]'); + await page.getByLabel('Titel').fill('Weihnachtsbrief 1965'); + const PDF_FIXTURE = path.resolve(__dirname, 'fixtures/minimal.pdf'); + const fileInput = page.locator('input[type="file"]'); + await fileInput.setInputFiles({ + name: 'Brief_1965.pdf', + mimeType: 'application/pdf', + buffer: fs.readFileSync(PDF_FIXTURE) + }); + await expect(page.getByLabel('Titel')).toHaveValue('Weihnachtsbrief 1965'); + await page.screenshot({ path: 'test-results/e2e/document-new-title-not-overwritten.png' }); + }); }); test.describe('Document creation', () => { @@ -97,6 +134,21 @@ test.describe('Document creation', () => { await expect(page.getByRole('heading', { name: 'E2E Testbrief' })).toBeVisible(); await page.screenshot({ path: 'test-results/e2e/document-create.png' }); }); + + test('user saves a document with only a file — title comes from filename', async ({ page }) => { + await page.goto('/documents/new'); + await page.waitForSelector('[data-hydrated]'); + const PDF_FIXTURE = path.resolve(__dirname, 'fixtures/minimal.pdf'); + await page.locator('input[type="file"]').setInputFiles({ + name: 'Brief_1965.pdf', + mimeType: 'application/pdf', + buffer: fs.readFileSync(PDF_FIXTURE) + }); + await page.getByRole('button', { name: 'Speichern', exact: true }).click(); + await expect(page).toHaveURL(/\/documents\/[^/]+$/); + await expect(page.getByRole('heading', { name: 'Brief_1965' })).toBeVisible(); + await page.screenshot({ path: 'test-results/e2e/document-create-file-only.png' }); + }); }); test.describe('Document editing', () => { @@ -327,10 +379,12 @@ test.describe('PDF annotations — admin', () => { await page.waitForSelector('[data-hydrated]'); await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 20000 }); - // Ensure annotation is visible before enabling annotate mode + // Ensure at least one annotation is visible before enabling annotate mode await expect(page.locator('[data-testid^="annotation-"]').first()).toBeVisible({ timeout: 8000 }); + // Record count now — the draw test may have created more than one annotation + const countBefore = await page.locator('[data-testid^="annotation-"]').count(); // Enable annotate mode to show delete buttons await page.getByRole('button', { name: /^annotieren$/i }).click(); @@ -339,7 +393,7 @@ test.describe('PDF annotations — admin', () => { await expect(deleteBtn).toBeVisible({ timeout: 8000 }); await deleteBtn.click(); - await expect(page.locator('[data-testid^="annotation-"]')).toHaveCount(0, { + await expect(page.locator('[data-testid^="annotation-"]')).toHaveCount(countBefore - 1, { timeout: 8000 }); @@ -407,7 +461,10 @@ test.describe('PDF annotations — file hash versioning', () => { await page.waitForSelector('[data-hydrated]'); await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 20000 }); - await expect(page.locator('[data-testid^="annotation-"]')).toHaveCount(0, { timeout: 8000 }); + // Use :not() to exclude the outdated-notice element whose testid also starts with "annotation-" + await expect( + page.locator('[data-testid^="annotation-"]:not([data-testid="annotation-outdated-notice"])') + ).toHaveCount(0, { timeout: 8000 }); await expect(page.locator('[data-testid="annotation-outdated-notice"]')).toBeVisible({ timeout: 5000 }); diff --git a/frontend/messages/de.json b/frontend/messages/de.json index e9d4d967..16718738 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -39,7 +39,7 @@ "form_placeholder_location": "z.B. Berlin, Wien…", "form_label_sender": "Absender", "form_label_receivers": "Empfänger", - "form_label_title": "Titel *", + "form_label_title": "Titel", "form_label_tags": "Schlagworte", "form_label_content": "Inhalt", "form_placeholder_content": "Kurze Beschreibung des Inhalts…", @@ -75,6 +75,7 @@ "doc_file_replace_label": "Neue Datei hochladen", "doc_file_replace_note": "(ersetzt die aktuelle Datei)", "doc_current_file_label": "Aktuelle Datei:", + "doc_more_details": "Weitere Details", "doc_new_heading": "Neues Dokument", "doc_edit_heading": "Bearbeiten", "doc_section_details": "Details", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 61e34dc9..462dea3b 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -39,7 +39,7 @@ "form_placeholder_location": "e.g. Berlin, Vienna…", "form_label_sender": "Sender", "form_label_receivers": "Recipients", - "form_label_title": "Title *", + "form_label_title": "Title", "form_label_tags": "Tags", "form_label_content": "Content", "form_placeholder_content": "Brief description of the content…", @@ -75,6 +75,7 @@ "doc_file_replace_label": "Upload new file", "doc_file_replace_note": "(replaces the current file)", "doc_current_file_label": "Current file:", + "doc_more_details": "More details", "doc_new_heading": "New document", "doc_edit_heading": "Edit", "doc_section_details": "Details", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 51d7ebe8..0ae0b91a 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -39,7 +39,7 @@ "form_placeholder_location": "p.ej. Berlín, Viena…", "form_label_sender": "Remitente", "form_label_receivers": "Destinatarios", - "form_label_title": "Título *", + "form_label_title": "Título", "form_label_tags": "Etiquetas", "form_label_content": "Contenido", "form_placeholder_content": "Breve descripción del contenido…", @@ -75,6 +75,7 @@ "doc_file_replace_label": "Subir nuevo archivo", "doc_file_replace_note": "(reemplaza el archivo actual)", "doc_current_file_label": "Archivo actual:", + "doc_more_details": "Más detalles", "doc_new_heading": "Nuevo documento", "doc_edit_heading": "Editar", "doc_section_details": "Detalles", diff --git a/frontend/src/lib/components/document/DescriptionSection.svelte b/frontend/src/lib/components/document/DescriptionSection.svelte index 6d2c13c4..b83ac296 100644 --- a/frontend/src/lib/components/document/DescriptionSection.svelte +++ b/frontend/src/lib/components/document/DescriptionSection.svelte @@ -9,7 +9,8 @@ let { initialDocumentLocation = '', initialSummary = '', titleRequired = false, - suggestedTitle = '' + suggestedTitle = '', + hideTitle = false }: { tags?: string[]; initialTitle?: string; @@ -17,17 +18,12 @@ let { initialSummary?: string; titleRequired?: boolean; suggestedTitle?: string; + hideTitle?: boolean; } = $props(); -let titleValue = $state(untrack(() => initialTitle)); let titleDirty = $state(false); - -$effect(() => { - const suggested = suggestedTitle; - if (suggested && !untrack(() => titleDirty)) { - titleValue = suggested; - } -}); +let titleOverride = $state(untrack(() => initialTitle)); +let titleValue = $derived(titleDirty ? titleOverride : suggestedTitle || titleOverride);
@@ -36,25 +32,27 @@ $effect(() => {
- -
- - { - 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" - /> -
+ {#if !hideTitle} + +
+ + { + titleOverride = (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" + /> +
+ {/if}
diff --git a/frontend/src/routes/documents/new/+page.svelte b/frontend/src/routes/documents/new/+page.svelte index ac2d8a6f..5e84accb 100644 --- a/frontend/src/routes/documents/new/+page.svelte +++ b/frontend/src/routes/documents/new/+page.svelte @@ -17,6 +17,30 @@ let selectedReceivers: { id: string; firstName: string; lastName: string }[] = $ ); let parsedSuggestion = $state({}); + +// Title is derived from the filename suggestion unless the user has typed something +let titleDirty = $state(false); +let titleOverride = $state(''); +let titleValue = $derived( + titleDirty ? titleOverride : (parsedSuggestion.suggestedTitle ?? titleOverride) +); + +// Details panel: starts open when prefill data is present or a form error occurred. +// Auto-opens when filename parsing finds a date/sender, but never force-closes — user +// can always collapse the section manually. +let detailsOpen = $state( + !!( + untrack(() => data.initialSenderId) || + untrack(() => data.initialReceivers).length > 0 || + untrack(() => form)?.error + ) +); + +$effect(() => { + if (parsedSuggestion.dateIso || senderId || selectedReceivers.length > 0) { + detailsOpen = true; + } +});
@@ -49,21 +73,51 @@ let parsedSuggestion = $state({}); {/if}
- - - + (parsedSuggestion = r)} /> + +
+ + { + titleOverride = (e.target as HTMLInputElement).value; + titleDirty = true; + }} + class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:border-ink focus:ring-ink" + placeholder="Titel eingeben…" + /> +
+ + +
+ + {m.doc_more_details()} + +
+ + + +
+
+
import { m } from '$lib/paraglide/messages.js'; -import { parseFilename, type FilenameParseResult } from '$lib/utils/filename'; +import { parseFilename, stripExtension, type FilenameParseResult } from '$lib/utils/filename'; let { onfileParsed @@ -8,31 +8,52 @@ let { onfileParsed?: (result: FilenameParseResult) => void; } = $props(); +let selectedFilename = $state(null); + function handleFileChange(e: Event) { const file = (e.target as HTMLInputElement).files?.[0]; - if (file) onfileParsed?.(parseFilename(file.name)); + if (!file) return; + selectedFilename = file.name; + const parsed = parseFilename(file.name); + const result: FilenameParseResult = { + ...parsed, + suggestedTitle: parsed.suggestedTitle ?? stripExtension(file.name) + }; + onfileParsed?.(result); } -
-

- {m.doc_section_file()} -

+
+
+

+ {m.doc_section_file()} +

+
-