Compare commits
2 Commits
29a71f4421
...
bbfef9a22d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbfef9a22d | ||
|
|
332b5b3c40 |
@@ -2,7 +2,9 @@ package org.raddatz.familienarchiv.controller;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
|
||||
@@ -103,6 +105,40 @@ public class DocumentController {
|
||||
}
|
||||
}
|
||||
|
||||
// --- QUICK UPLOAD ---
|
||||
|
||||
private static final Set<String> ALLOWED_CONTENT_TYPES = Set.of(
|
||||
"application/pdf", "image/jpeg", "image/png", "image/tiff");
|
||||
|
||||
public record QuickUploadResult(List<Document> created, List<String> errors) {}
|
||||
|
||||
@PostMapping(value = "/quick-upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@RequirePermission(Permission.WRITE_ALL)
|
||||
public QuickUploadResult quickUpload(
|
||||
@RequestPart(value = "files", required = false) List<MultipartFile> files) {
|
||||
List<Document> created = new ArrayList<>();
|
||||
List<String> errors = new ArrayList<>();
|
||||
|
||||
if (files == null || files.isEmpty()) {
|
||||
return new QuickUploadResult(created, errors);
|
||||
}
|
||||
|
||||
for (MultipartFile file : files) {
|
||||
if (!ALLOWED_CONTENT_TYPES.contains(file.getContentType())) {
|
||||
errors.add(file.getOriginalFilename() + ": unsupported file type");
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
created.add(documentService.storeDocument(file));
|
||||
} catch (Exception e) {
|
||||
errors.add(file.getOriginalFilename() + ": " + e.getMessage());
|
||||
log.warn("Quick upload failed for file {}: {}", file.getOriginalFilename(), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return new QuickUploadResult(created, errors);
|
||||
}
|
||||
|
||||
@GetMapping("/search")
|
||||
public ResponseEntity<List<Document>> search(
|
||||
@RequestParam(required = false) String q,
|
||||
|
||||
@@ -61,7 +61,7 @@ public class DocumentService {
|
||||
} else {
|
||||
document = Document.builder()
|
||||
.originalFilename(originalFilename)
|
||||
.title(originalFilename)
|
||||
.title(stripExtension(originalFilename))
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.build();
|
||||
}
|
||||
@@ -307,6 +307,12 @@ public class DocumentService {
|
||||
|
||||
// ─── private helpers ──────────────────────────────────────────────────────
|
||||
|
||||
private static String stripExtension(String filename) {
|
||||
if (filename == null) return null;
|
||||
int dot = filename.lastIndexOf('.');
|
||||
return dot > 0 ? filename.substring(0, dot) : filename;
|
||||
}
|
||||
|
||||
private static String sha256Hex(byte[] bytes) {
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
|
||||
@@ -23,6 +23,7 @@ import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.hamcrest.Matchers.containsString;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
@@ -121,6 +122,50 @@ class DocumentControllerTest {
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
// ─── POST /api/documents/quick-upload ────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void quickUpload_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(multipart("/api/documents/quick-upload"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void quickUpload_returns403_whenMissingWritePermission() throws Exception {
|
||||
mockMvc.perform(multipart("/api/documents/quick-upload"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void quickUpload_returns200_withValidPdfFile() throws Exception {
|
||||
Document doc = Document.builder()
|
||||
.id(UUID.randomUUID()).title("scan001").originalFilename("scan001.pdf").build();
|
||||
when(documentService.storeDocument(any())).thenReturn(doc);
|
||||
|
||||
org.springframework.mock.web.MockMultipartFile file =
|
||||
new org.springframework.mock.web.MockMultipartFile("files", "scan001.pdf", "application/pdf", new byte[]{1});
|
||||
|
||||
mockMvc.perform(multipart("/api/documents/quick-upload").file(file))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.created[0].title").value("scan001"))
|
||||
.andExpect(jsonPath("$.errors").isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void quickUpload_skipsUnsupportedFileType_andReturnsError() throws Exception {
|
||||
org.springframework.mock.web.MockMultipartFile file =
|
||||
new org.springframework.mock.web.MockMultipartFile("files", "report.docx",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document", new byte[]{1});
|
||||
|
||||
mockMvc.perform(multipart("/api/documents/quick-upload").file(file))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.created").isEmpty())
|
||||
.andExpect(jsonPath("$.errors[0]").value(containsString("report.docx")));
|
||||
}
|
||||
|
||||
// ─── GET /api/documents/{id}/versions ────────────────────────────────────
|
||||
|
||||
@Test
|
||||
|
||||
@@ -212,6 +212,44 @@ class DocumentServiceTest {
|
||||
verify(documentVersionService).recordVersion(any(Document.class));
|
||||
}
|
||||
|
||||
// ─── storeDocument ───────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void storeDocument_setsTitle_withoutFileExtension_forNewDocument() throws Exception {
|
||||
org.springframework.mock.web.MockMultipartFile file =
|
||||
new org.springframework.mock.web.MockMultipartFile("file", "scan001.pdf", "application/pdf", new byte[]{1});
|
||||
FileService.UploadResult uploadResult = new FileService.UploadResult("documents/uuid_scan001.pdf", "abc123");
|
||||
Document saved = Document.builder().id(UUID.randomUUID()).title("scan001").originalFilename("scan001.pdf").build();
|
||||
|
||||
when(documentRepository.findByOriginalFilename("scan001.pdf")).thenReturn(Optional.empty());
|
||||
when(documentRepository.save(any())).thenReturn(saved);
|
||||
when(fileService.uploadFile(any(), any())).thenReturn(uploadResult);
|
||||
|
||||
org.mockito.ArgumentCaptor<Document> captor = org.mockito.ArgumentCaptor.forClass(Document.class);
|
||||
documentService.storeDocument(file);
|
||||
|
||||
verify(documentRepository).save(captor.capture());
|
||||
assertThat(captor.getValue().getTitle()).isEqualTo("scan001");
|
||||
}
|
||||
|
||||
@Test
|
||||
void storeDocument_preservesExistingTitle_whenPlaceholderAlreadyExists() throws Exception {
|
||||
org.springframework.mock.web.MockMultipartFile file =
|
||||
new org.springframework.mock.web.MockMultipartFile("file", "scan001.pdf", "application/pdf", new byte[]{1});
|
||||
FileService.UploadResult uploadResult = new FileService.UploadResult("documents/uuid_scan001.pdf", "abc123");
|
||||
Document placeholder = Document.builder()
|
||||
.id(UUID.randomUUID()).title("Brief an Oma").originalFilename("scan001.pdf")
|
||||
.status(org.raddatz.familienarchiv.model.DocumentStatus.PLACEHOLDER).build();
|
||||
|
||||
when(documentRepository.findByOriginalFilename("scan001.pdf")).thenReturn(Optional.of(placeholder));
|
||||
when(documentRepository.save(any())).thenReturn(placeholder);
|
||||
when(fileService.uploadFile(any(), any())).thenReturn(uploadResult);
|
||||
|
||||
documentService.storeDocument(file);
|
||||
|
||||
assertThat(placeholder.getTitle()).isEqualTo("Brief an Oma");
|
||||
}
|
||||
|
||||
// ─── backfillFileHashes ───────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
|
||||
@@ -265,5 +265,10 @@
|
||||
"doc_panel_annotation_thread_title": "Annotation",
|
||||
"doc_panel_discussion_annotation_tab": "Annotation · Seite {page}",
|
||||
"pdf_annotations_show": "Annotierungen anzeigen",
|
||||
"pdf_annotations_hide": "Annotierungen verbergen"
|
||||
"pdf_annotations_hide": "Annotierungen verbergen",
|
||||
"upload_drop_hint": "Dateien ablegen oder auswählen",
|
||||
"upload_accepted_types": "PDF, JPEG, PNG, TIFF",
|
||||
"upload_success": "{count} Dokument(e) erstellt",
|
||||
"upload_invalid_type": "{filename}: Dateiformat nicht unterstützt",
|
||||
"upload_error": "Fehler beim Hochladen von {filename}"
|
||||
}
|
||||
|
||||
@@ -265,5 +265,10 @@
|
||||
"doc_panel_annotation_thread_title": "Annotation",
|
||||
"doc_panel_discussion_annotation_tab": "Annotation · Page {page}",
|
||||
"pdf_annotations_show": "Show annotations",
|
||||
"pdf_annotations_hide": "Hide annotations"
|
||||
"pdf_annotations_hide": "Hide annotations",
|
||||
"upload_drop_hint": "Drop files or click to select",
|
||||
"upload_accepted_types": "PDF, JPEG, PNG, TIFF",
|
||||
"upload_success": "{count} document(s) created",
|
||||
"upload_invalid_type": "{filename}: unsupported file format",
|
||||
"upload_error": "Error uploading {filename}"
|
||||
}
|
||||
|
||||
@@ -265,5 +265,10 @@
|
||||
"doc_panel_annotation_thread_title": "Anotación",
|
||||
"doc_panel_discussion_annotation_tab": "Anotación · Página {page}",
|
||||
"pdf_annotations_show": "Mostrar anotaciones",
|
||||
"pdf_annotations_hide": "Ocultar anotaciones"
|
||||
"pdf_annotations_hide": "Ocultar anotaciones",
|
||||
"upload_drop_hint": "Soltar archivos o hacer clic para seleccionar",
|
||||
"upload_accepted_types": "PDF, JPEG, PNG, TIFF",
|
||||
"upload_success": "{count} documento(s) creado(s)",
|
||||
"upload_invalid_type": "{filename}: formato de archivo no admitido",
|
||||
"upload_error": "Error al subir {filename}"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { goto, invalidateAll } from '$app/navigation';
|
||||
import TagInput from '$lib/components/TagInput.svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { untrack } from 'svelte';
|
||||
@@ -18,6 +18,13 @@ let senderId = $state(untrack(() => data.filters?.senderId || ''));
|
||||
let receiverId = $state(untrack(() => data.filters?.receiverId || ''));
|
||||
let tagNames = $state<string[]>(untrack(() => data.filters?.tags || []));
|
||||
|
||||
const ACCEPTED_TYPES = ['application/pdf', 'image/jpeg', 'image/png', 'image/tiff'];
|
||||
|
||||
let isDragging = $state(false);
|
||||
let isUploading = $state(false);
|
||||
let uploadMessages = $state<{ text: string; isError: boolean }[]>([]);
|
||||
let fileInput: HTMLInputElement;
|
||||
|
||||
let searchTimer: ReturnType<typeof setTimeout>;
|
||||
|
||||
const hasAdvancedFilters = (filters: typeof data.filters) =>
|
||||
@@ -29,6 +36,81 @@ const hasAdvancedFilters = (filters: typeof data.filters) =>
|
||||
|
||||
let showAdvanced = $state(untrack(() => hasAdvancedFilters(data.filters)));
|
||||
|
||||
function handleDragOver(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
isDragging = true;
|
||||
}
|
||||
|
||||
function handleDragLeave() {
|
||||
isDragging = false;
|
||||
}
|
||||
|
||||
async function handleDrop(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
isDragging = false;
|
||||
const files = Array.from(e.dataTransfer?.files ?? []);
|
||||
await uploadFiles(files);
|
||||
}
|
||||
|
||||
async function handleFileSelect(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const files = Array.from(input.files ?? []);
|
||||
input.value = '';
|
||||
await uploadFiles(files);
|
||||
}
|
||||
|
||||
async function uploadFiles(files: File[]) {
|
||||
if (files.length === 0) return;
|
||||
|
||||
const messages: { text: string; isError: boolean }[] = [];
|
||||
|
||||
// Client-side type validation
|
||||
const valid: File[] = [];
|
||||
for (const file of files) {
|
||||
if (!ACCEPTED_TYPES.includes(file.type)) {
|
||||
messages.push({ text: m.upload_invalid_type({ filename: file.name }), isError: true });
|
||||
} else {
|
||||
valid.push(file);
|
||||
}
|
||||
}
|
||||
|
||||
if (valid.length === 0) {
|
||||
uploadMessages = messages;
|
||||
return;
|
||||
}
|
||||
|
||||
isUploading = true;
|
||||
try {
|
||||
const formData = new FormData();
|
||||
for (const file of valid) {
|
||||
formData.append('files', file);
|
||||
}
|
||||
|
||||
const res = await fetch('/api/documents/quick-upload', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const result = await res.json();
|
||||
if (result.created?.length > 0) {
|
||||
messages.push({ text: m.upload_success({ count: result.created.length }), isError: false });
|
||||
}
|
||||
for (const err of result.errors ?? []) {
|
||||
messages.push({ text: err, isError: true });
|
||||
}
|
||||
await invalidateAll();
|
||||
} else {
|
||||
for (const file of valid) {
|
||||
messages.push({ text: m.upload_error({ filename: file.name }), isError: true });
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
isUploading = false;
|
||||
uploadMessages = messages;
|
||||
}
|
||||
}
|
||||
|
||||
function triggerSearch() {
|
||||
const params = new SvelteURLSearchParams();
|
||||
|
||||
@@ -210,6 +292,43 @@ $effect(() => {
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if data.canWrite}
|
||||
<!-- UPLOAD DROP ZONE -->
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
class="mb-4 flex cursor-pointer items-center justify-center gap-3 border border-dashed px-6 py-3 text-sm transition-colors duration-150 {isDragging
|
||||
? 'border-accent bg-accent/5 text-accent'
|
||||
: 'border-line-2 text-ink-3 hover:border-accent hover:text-accent'}"
|
||||
ondragover={handleDragOver}
|
||||
ondragleave={handleDragLeave}
|
||||
ondrop={handleDrop}
|
||||
onclick={() => fileInput.click()}
|
||||
onkeydown={(e) => e.key === 'Enter' && fileInput.click()}
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Upload/Upload-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-4 w-4 shrink-0 opacity-50"
|
||||
/>
|
||||
<span class="font-sans font-medium">
|
||||
{isUploading ? '…' : m.upload_drop_hint()}
|
||||
</span>
|
||||
<span class="font-sans text-xs text-ink-3">{m.upload_accepted_types()}</span>
|
||||
</div>
|
||||
|
||||
{#if uploadMessages.length > 0}
|
||||
<div class="mb-4 flex flex-col gap-1">
|
||||
{#each uploadMessages as msg, i (i)}
|
||||
<p class="font-sans text-sm {msg.isError ? 'text-red-600' : 'text-green-700'}">
|
||||
{msg.text}
|
||||
</p>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- DOCUMENT LIST HEADER -->
|
||||
<div class="mb-2 flex justify-end">
|
||||
{#if data.canWrite}
|
||||
@@ -360,4 +479,12 @@ $effect(() => {
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<input
|
||||
bind:this={fileInput}
|
||||
type="file"
|
||||
multiple
|
||||
accept=".pdf,.jpg,.jpeg,.png,.tif,.tiff"
|
||||
class="sr-only"
|
||||
onchange={handleFileSelect}
|
||||
/>
|
||||
</main>
|
||||
|
||||
Reference in New Issue
Block a user