Compare commits

...

146 Commits

Author SHA1 Message Date
Marcel
c59287fcfc fix(bulk-edit): cycle-3 polish — Felix C2/C3/C4/C5 + Sara coverage gaps
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m54s
CI / OCR Service Tests (pull_request) Successful in 39s
CI / Backend Unit Tests (pull_request) Failing after 2m56s
CI / Unit & Component Tests (push) Failing after 3m6s
CI / Backend Unit Tests (push) Failing after 2m56s
CI / OCR Service Tests (push) Successful in 34s
Felix C2 — `BatchMetadataRequest` controller now uses `@Valid` so future
@Size/etc. annotations on the record actually fire.

Felix C3 — Auto-clear `$effect` in `+layout.svelte` reads
`bulkSelectionStore.size` inside `untrack()` so the effect only re-fires on
route change, not on every checkbox toggle.

Felix C4 — `BulkDocumentEditLayout` edit-mode hydration loop now lives
inside `onMount` (not at top-level script) so the SvelteMap mutation is
unambiguously tied to instance lifecycle, matching the pattern used by
`WhoWhenSection`/`DescriptionSection` after the cycle-2 fix.

Felix C5 — Replaced fully-qualified `java.util.LinkedHashSet` in
`DocumentController` with a top-of-file import.

Sara coverage — six new spec files / blocks pin the cycle-1 and cycle-2
behaviours that were previously untested:
 - `WhoWhenSection.svelte.spec.ts` — onMount seeding from initialDateIso /
   initialLocation; doesn't stomp parent-bound dateIso; hideDate / editMode
   branch
 - `DescriptionSection.svelte.spec.ts` — onMount seeding from initialTitle /
   initialDocumentLocation; doesn't stomp parent-bound values; archive-box /
   archive-folder fields visible only in editMode
 - `BulkSelectionBar.svelte.spec.ts` — Esc-scope guard tests for `<dialog>`
   open and `aria-expanded` popover present
 - `BulkDocumentEditLayout.svelte.spec.ts` — topbar reads
   "Massenbearbeitung" + "werden bearbeitet" in edit mode (not the
   upload-flavoured "hochladen"/"werden erstellt" copy)
 - `DocumentControllerTest.patchBulk_returns400_whenArchiveBoxExceeds255Chars`
   — pins the @Size validator on archiveBox via the @Valid wiring

Refs #225, PR #331

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 19:18:56 +02:00
Marcel
8ce96294b0 fix(bulk-edit): cycle-2 blockers — restore initial-* props, missing import, scope Esc, edit-mode topbar
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m50s
CI / OCR Service Tests (pull_request) Successful in 27s
CI / Backend Unit Tests (pull_request) Failing after 2m54s
CI / Unit & Component Tests (push) Failing after 2m49s
CI / OCR Service Tests (push) Successful in 30s
CI / Backend Unit Tests (push) Failing after 2m55s
Felix B1 (data-loss regression on /documents/[id]/edit) — DocumentEditLayout
still passes initialDateIso, initialLocation, initialDocumentLocation, but
my cycle-1 cleanup removed those props. Result: existing values rendered
empty and a save would have overwritten them with "". Restored the props
on WhoWhenSection and DescriptionSection; initialisation now lives in
onMount so it runs exactly once and never stomps a parent-driven update on
a later prop change.

Felix B2 — `DescriptionSection.svelte:36` still had the top-level
`currentTitle = untrack(() => initialTitle)` mutation that I cleaned up in
WhoWhenSection but missed here. Same onMount-once treatment.

Leonie B5 — `enrich/+page.svelte:105` referenced `<BulkSelectionBar>` but
the import was lost in a prettier pass; svelte-check errored out and the
bar never rendered, leaving an 8 rem dead zone from the pb-32 reservation.
One-line fix: add the import.

Leonie B6 — Esc handler in `BulkSelectionBar` was unscoped and stole
Escape from NotificationBell, ConfirmDialog, HelpPopover, etc. (e.g.
selecting docs → opening notification bell → Esc would close the bell
AND silently wipe the selection). Now bails when an open dialog,
expanded menu, or popover is detected.

Elicit C1 — `BulkDocumentEditLayout` topbar now branches on `mode`:
shows "Massenbearbeitung" + "{count} werden bearbeitet" in edit mode
instead of the upload-flavoured "Mehrere Dokumente hochladen" + "werden
erstellt" copy. New i18n keys `bulk_edit_topbar_title` and
`bulk_edit_count_pill` in DE/EN/ES.

Tests added:
 - DocumentControllerTest.patchBulk_stripsCarriageReturnsAndNewlinesFromErrorMessages
   (Sara C2 follow-up — pin sanitizeForLog as a regression test)
 - BulkSelectionBar.spec — count=1 → "1 Dokument", count=2 → "2 Dokumente"
   (Sara C6 follow-up — pin the new bulk_edit_n_selected_one/_other branch)

Refs #225, PR #331

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 17:17:33 +02:00
Marcel
1803db86b5 test(bulk-edit): plug Sara's identified coverage gaps
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 3m1s
CI / OCR Service Tests (pull_request) Successful in 30s
CI / Backend Unit Tests (pull_request) Failing after 3m0s
CI / Unit & Component Tests (push) Failing after 2m59s
CI / OCR Service Tests (push) Successful in 37s
CI / Backend Unit Tests (push) Failing after 2m54s
- DocumentServiceTest.applyBulkEditToDocument_propagatesDomainException_whenSenderIdUnresolvable (Sara C1)
- DocumentServiceTest.findIdsForFilter_passesTagOperatorOR_throughBuildSearchSpec (Sara C3)
- bulkSelection.svelte.spec.ts: setAll([]) no-op + previous-IDs-absent + ids getter (Sara C4 + S4)
- /documents/bulk-edit/+page.server.ts now defensively handles a UserGroup
  with NULL `permissions` (treats it as not-WRITE_ALL instead of throwing
  on .includes()) + matching test (Sara C7)

233 backend tests + frontend bulk-edit specs all green.

Refs #225, PR #331

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 16:58:48 +02:00
Marcel
46001bbf9d refactor(documents): extract buildSearchSpec and resolveTags helpers
Markus #3 / Felix B2 — kill the duplicated spec-chain across
findIdsForFilter and searchDocuments, and centralise the
"name string → Tag (find or create)" loop that updateDocumentTags and
applyBulkEditToDocument were each carrying their own copy of.

`buildSearchSpec` is the single source of truth for the seven-spec chain
(text + date range + sender + receiver + tags + tag-prefix + status). Both
callers do their own FTS short-circuit, then delegate.

`resolveTags` is the single source of truth for trimming, blank-skipping,
and find-or-create through TagService. Both updateDocumentTags (replace
semantics) and applyBulkEditToDocument (additive merge) consume it.

No behaviour change. All 231 backend tests still green.

Refs #225, PR #331

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 16:52:38 +02:00
Marcel
af8303dbf8 fix(bulk-edit): auto-clear selection store when leaving /documents and /enrich
Felix C4 — bulkSelectionStore is module-singleton; before this change it
silently followed the user from /documents to /persons / /admin / etc.,
then reappeared as a stale count when they wandered back. Root +layout.svelte
now watches page.url.pathname and clears the store the moment the user
leaves the two routes that surface BulkSelectionBar.

Refs #225, PR #331

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 16:49:07 +02:00
Marcel
7df00859c6 fix(bulk-edit): pluralization, edit-mode CTA, error UI, real loading state
Elicit C1+C3 — bulk-selection count uses ICU-style plural keys
(bulk_edit_n_selected_one / _other) so n=1 reads as "1 Dokument" instead
of "1 Dokumente". Save CTA in edit mode reads "Anwenden" via the existing
bulk_edit_save_button key; UploadSaveBar grew an editMode prop. Multi-
chunk progress text is now visible (not aria-only).

Felix C2 — bulk-edit page wires the backend error code through
parseBackendError + getErrorMessage instead of falling back to a generic
internal_error.

Felix C5 — editAllMatching no longer swallows fetch failures: the button
shows an inline error with the backend-mapped message (e.g. when the
filter cap is exceeded).

Leonie C8 — replace the literal "…" loading glyph on /documents/bulk-edit
with a spinner + role=status + aria-live=polite + visible "Loading
documents…" text.

Leonie C9 — partial-failure card and bulk-edit page error card now use
the design-system `text-danger` / `bg-danger/10` / `border-danger/40`
tokens (dark-mode safe) instead of raw red palette values.

Leonie C10 + C13 — German plural fixed; EN badges retensed
("+ added" → "+ will be added", "replaced" → "will replace") to match
the future-tense intent of DE/ES.

Refs #225, PR #331

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 16:46:58 +02:00
Marcel
92d623e298 chore(bulk-edit): bean validation on DTO, readOnly tx, imports
Tobias C2 — DocumentBulkEditDTO carries @Size guards on tagNames (max 200
entries × 200 chars), receiverIds (max 200), and the three location strings
(max 255 chars each). Controller now uses @Valid on @RequestBody so they
fire. The 500-cap on documentIds stays as a controller-level check (typed
BULK_EDIT_TOO_MANY_IDS code, not generic VALIDATION_ERROR).

Markus #7 — replace fully-qualified type names inside DocumentService with
imports (DocumentBatchSummary, DocumentBulkEditDTO).

Markus #8 — @Transactional(readOnly = true) on findIdsForFilter and
batchMetadata. Both are pure read paths; the marker lets Hibernate skip
dirty-checking on the loaded entities.

Record conversion of DocumentBulkEditDTO (Markus #6 / Felix #3) deferred
to a follow-up — keeping @Data avoids 10+ test bodies that mutate the DTO
via setters; the inconsistency is documented in the DTO's class-level
Javadoc.

Refs #225, PR #331

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 16:41:06 +02:00
Marcel
156efe8b31 fix(bulk-edit): a11y + i18n hardening (Leonie blockers 1–4 + quick concerns)
B1 — i18n the archive-box / archive-folder labels and add helper text.
Karton/Mappe were hardcoded German and broke EN/ES locales (WCAG 3.1.2).

B2 — drop the hardcoded German aria-label on the onboarding callout.
role="note" + the visible localised text is self-describing; the redundant
label was overriding the translated content for AT users on EN/ES.

B3 — Escape clears the bulk selection while the bar is visible. Adds an
"Esc: Auswahl aufheben" hint visible at ≥ sm (WCAG 2.1.1).

B4 — /documents and /enrich reserve pb-32 when the bulk-selection bar is
visible so it doesn't occlude the last row or pagination (WCAG 1.4.10).

Folded in three Leonie quick-concerns:
 - C5: badge text-[10px] → text-[11px], raw text-gray-600 →
        design-token text-ink-2 (dark-mode safe)
 - C7: aria-live="polite" on bulk-selection-count
 - C11: "Alles aufheben" → "Auswahl aufheben" (DE/EN/ES) — disambiguates
        from "discard the operation entirely"

Refs #225, PR #331

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 16:35:40 +02:00
Marcel
499beca124 fix(bulk-edit): drop dead initial-* props and clear store on edit-mode discard
Felix B1 — `WhoWhenSection.svelte:37` and `DescriptionSection.svelte:42`
mutated $bindable props at top-level script scope, seeding them from
`initial*` companion props that no caller ever passes. The pattern stomps
parent-owned state in any future component re-evaluation.

Removed the dead initialDateIso / initialLocation / initialDocumentLocation
props and let the bindables carry their own initial value. dateDisplay and
currentTitle now seed from the bindable directly inside untrack — no
re-assignment required.

Elicit B2 — In edit mode the file map IS the user's bulk selection, so
discarding must clear bulkSelectionStore and bounce back to /documents,
otherwise the user is left on /documents/bulk-edit with an empty form
and a stale count in the bottom bar.

Refs #225, PR #331

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 16:29:44 +02:00
Marcel
5cbb14d4a3 fix(bulk-edit): backend hardening — audit, caps, dedupe, CRLF, WRITE_ALL on /ids
Addresses Markus B1+B2, Nora C1+C4+C5, Tobias #1, Sara B1+B2+C2, Elicit S2+C4
from the cycle 1 review on PR #331.

Audit / version trail
  applyBulkEditToDocument now takes actorId, calls
  documentVersionService.recordVersion(saved), and emits an
  AuditKind.METADATA_UPDATED event tagged source=BULK_EDIT — restoring parity
  with the single-doc updateDocument path.

Caps
  /api/documents/batch-metadata: 500-ID cap (matches PATCH cap)
  /api/documents/ids: 5000 result cap with BULK_EDIT_TOO_MANY_IDS on overflow

Permission tightening
  /api/documents/ids re-gated WRITE_ALL — its only consumer is the bulk-edit
  fast path (least-privilege per Elicit S2 + Nora's defence-in-depth).

Audit log
  /ids and /batch-metadata now emit one log.info per call, mirroring the
  quickUpload + bulkEdit format.

Robustness
  Duplicates in PATCH documentIds are de-duplicated via LinkedHashSet so a
  double-clicked "Alle X editieren" cannot inflate the updated count.
  log.warn lines that interpolate Throwable.getMessage() now run through a
  CRLF-strip helper (CWE-117).

Tests added
  applyBulkEditToDocument_recordsVersion_andLogsAuditEvent_taggedSourceBulkEdit
  patchBulk_acceptsExactly500Ids_atTheCap (off-by-one fence)
  patchBulk_dedupesDuplicateDocumentIds_doesNotInflateUpdatedCount
  getDocumentIds_returns403_forUserWithoutWriteAll
  getDocumentIds_returns400_whenResultExceedsFilterCap
  batchMetadata_returns403_forUserWithoutReadAll
  batchMetadata_returns400_whenIdsExceedsCap

All 231 backend tests green.

Refs #225, PR #331

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 16:24:03 +02:00
Marcel
2bb8fb8968 fix(bulk-edit): align BulkEditEntry shape with backend DocumentBatchSummary
Production bug — the backend serialises the document UUID as `id`, but
BulkEditEntry typed it as `documentId`. The runtime cast in /documents/
bulk-edit/+page.svelte was a TypeScript lie: every `entry.documentId`
became undefined, the SvelteMap collapsed all selections under the
undefined key, and the PATCH fired with `documentIds: []` (which the
controller correctly rejected with 400). Field semantics ACs could
therefore never fire end-to-end.

Renamed `BulkEditEntry.documentId` → `id`. The FileEntry built from each
summary still carries both `id` (local map key) and `documentId` (PATCH
payload) so the save handler is unchanged.

Reported by Elicit (B1) on PR #331.

Refs #225

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 16:14:53 +02:00
Marcel
f13f635161 test(bulk-edit): e2e coverage for selection bar and Massenbearbeitung flow
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m0s
CI / OCR Service Tests (push) Successful in 37s
CI / Backend Unit Tests (push) Failing after 2m53s
CI / Unit & Component Tests (pull_request) Failing after 3m0s
CI / OCR Service Tests (pull_request) Successful in 35s
CI / Backend Unit Tests (pull_request) Failing after 2m58s
Five Playwright scenarios on the bulk-edit feature:
 - sticky bar appears with count when checkboxes are toggled
 - Alles aufheben hides the bar
 - Massenbearbeitung navigates to /documents/bulk-edit and the edit-mode
   onboarding callout is rendered
 - direct navigation to /documents/bulk-edit with no selection redirects back
 - the same bar drives /enrich (skipped when the test DB has no incomplete docs)

Refs #225

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 15:30:18 +02:00
Marcel
6d3489d035 feat(bulk-edit): add /documents/bulk-edit route
Server load redirects READ_ALL-only users (or unauthenticated) to /documents.
Page load: onMount reads bulkSelectionStore — redirects to /documents when the
store is empty, otherwise POSTs the IDs to /api/documents/batch-metadata and
hands the resulting summaries to BulkDocumentEditLayout in mode="edit".

Refs #225

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 15:18:07 +02:00
Marcel
fa5dc43864 feat(bulk-edit): extend BulkDocumentEditLayout with mode="edit"
- New FieldLabelBadge component (additive / replace variants, WCAG AA contrast)
- WhoWhenSection: hideDate prop, editMode prop renders badges next to sender
  and receivers, hides the meta_location field
- DescriptionSection: editMode prop renders badges next to tags and archive
  fields; new bindable archiveBox / archiveFolder inputs only in editMode
- PersonTypeahead: optional badge prop forwards to FieldLabelBadge
- FileSwitcherStrip FileEntry: file is now optional, documentId added so
  edit-mode entries reference an existing document by UUID
- BulkDocumentEditLayout: mode prop branches drop zone / read-only title /
  callout / save handler. Edit save chunks 500 IDs per PATCH, stops on chunk
  failure with retry, marks per-document errors as chips, clears the bulk
  selection store on full success.

Refs #225

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 15:16:06 +02:00
Marcel
d4f32ed5d4 feat(bulk-edit): add BulkSelectionBar and Alle-X-editieren fast path
- BulkSelectionBar component: sticky bottom bar shown only when canWrite
  and selection is non-empty. Buttons meet WCAG 44px touch targets and
  iOS safe-area inset is honoured.
- Bar mounted on /documents and /enrich.
- Alle X editieren button on /documents replaces the selection with
  every UUID matching the active filter (via /api/documents/ids) and
  jumps to /documents/bulk-edit.

Refs #225

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 15:07:26 +02:00
Marcel
27e3d290e7 feat(bulk-edit): add canWrite-gated row checkboxes on /documents and /enrich
Each row in the document search list and the enrichment queue gets a
WCAG-compliant (44px touch target) checkbox bound to bulkSelectionStore.
Checkbox click does not trigger the row's stretched-link navigation —
it sits inside the z-10 content sibling, the link is in the z-0 sibling,
so click events do not bubble between them.

Refs #225

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 15:03:59 +02:00
Marcel
25446c9a5c feat(bulk-edit): add bulkSelection store backed by SvelteSet
Module-singleton live accumulator: selection persists across pagination
and route changes within /documents and /enrich. Cleared on successful
bulk save or via Alles aufheben.

Refs #225

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 14:54:59 +02:00
Marcel
660e34e016 feat(bulk-edit): add i18n keys, error mapping, and regenerate api types
- 14 new Paraglide keys in de/en/es for the bulk-edit UI strings (selection
  bar, callout, badges, save progress, retry, error)
- BULK_EDIT_TOO_MANY_IDS added to errors.ts type union and getErrorMessage()
- Regenerated api.ts now includes /api/documents/{bulk,batch-metadata,ids}
  and the DocumentBulkEditDTO / BulkEditResult / DocumentBatchSummary schemas

Refs #225

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 14:52:10 +02:00
Marcel
b662117e55 feat(bulk-edit): add GET /api/documents/ids endpoint
READ_ALL-gated endpoint returning all document UUIDs matching the same
filter parameters as /search, ignoring page/size. Powers the "Alle X
editieren" fast path so the bulk-edit page can replace the selection
with every match in one round-trip.

Refs #225

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 14:40:56 +02:00
Marcel
d251806e72 feat(bulk-edit): add POST /api/documents/batch-metadata endpoint
READ_ALL-gated batch endpoint returning lightweight summaries (id, title,
server PDF URL) for the bulk-edit page's left strip. Unknown IDs are silently
dropped — missing previews would be obvious to the user already.

Refs #225

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 14:38:08 +02:00
Marcel
f0da033ec9 feat(bulk-edit): add PATCH /api/documents/bulk endpoint
WRITE_ALL-gated batch endpoint that applies a partial DTO to up to 500
documents per request. Per-document failures (DOCUMENT_NOT_FOUND, etc.)
are collected into the response's errors[] without aborting the batch.
Logs an audit line consistent with quickUpload.

Refs #225

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 14:34:52 +02:00
Marcel
a59feec81a feat(bulk-edit): add DocumentService.applyBulkEditToDocument
Per-document atomic mutation method for the upcoming bulk PATCH endpoint.
Tags and receivers merge additively into existing sets; sender and the three
location fields replace only when the DTO field is non-blank. Wrapped in its
own @Transactional so a per-document failure cannot partially mutate other
documents in the outer batch loop.

Refs #225

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 14:31:48 +02:00
Marcel
779ffaab55 feat(bulk-edit): scaffold DTOs and BULK_EDIT_TOO_MANY_IDS error code
Adds the request/response shapes for the upcoming PATCH /api/documents/bulk,
POST /api/documents/batch-metadata, and the new error code for the 500-ID cap.

Refs #225

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 14:27:46 +02:00
Marcel
b690c74ddf fix(richtlinien): improve examples, copy, and Wikipedia link
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m57s
CI / OCR Service Tests (push) Successful in 34s
CI / Backend Unit Tests (push) Failing after 2m59s
- Rule cards now show before→after examples; strikethrough rule input
  renders with CSS line-through so the visual context is honest
- Illegible-words rule shows output only — can't represent unreadable
  text as readable characters
- Intro drops fictional family names in favour of "egal wer tippt"
- Wikipedia card copy is more direct; link uses icon instead of
  parenthetical "(öffnet in neuem Tab)" text

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 13:10:56 +02:00
Marcel
0797406f02 docs(bulk-upload): explain chunkSize=10 and 50-file cap constants
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m47s
CI / OCR Service Tests (push) Successful in 34s
CI / Backend Unit Tests (push) Failing after 2m56s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
c94d2cec03 feat(bulk-upload): guard discard-all with confirm dialog
Uses getConfirmService() (optional — null fallback when context is absent so
unit tests that don't exercise the discard path need no CONFIRM_KEY context)
and the new bulk_discard_confirm i18n key.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
4da0bf71a0 fix(bulk-upload): add gradient overflow indicators to chip strip
Adds pointer-events-none left/right gradient fade overlays on the
FileSwitcherStrip track div so mouse-only users can see when more
chips are hidden beyond the visible area. The scrollbar is hidden
(scrollbar-width:none) so gradients are the only overflow signal.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
da5d3c60b3 fix(bulk-upload): chip readability and focus management in FileSwitcherStrip
Chip label text increased from 11px to 12px (text-xs) and number badge
from 9px to 11px for the 60+ senior audience on laptops/tablets.

After removing a chip via the × button, focus moves to the previous chip
(falling back to the next chip when the first chip is removed) so keyboard
users are not stranded on <body>. Uses Svelte tick() to wait for DOM update.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
ed0d0bf331 fix(bulk-upload): handle network errors and partial upload success
save() now wraps each chunk fetch in try/catch — a thrown network error
marks all files in that chunk as errored. Also handles HTTP 200 responses
with a non-empty errors array (partial success): only the named filenames
are marked as errored rather than all files in the chunk. Navigation is
suppressed whenever any file fails.

Tests added:
- network error marks all chunk files as errored, no navigation
- HTTP 200 with errors array marks only affected files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
899508f9ca feat(bulk-upload): guard save() against concurrent invocations
Adds a saving $state flag that blocks re-entry while a chunk upload is
in flight. The UploadSaveBar save button is disabled via a new disabled
prop while saving is true. Tested: clicking Save twice fires fetch only
once.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
d32e671e9d fix(bulk-upload): raise discard button touch target to 44px for WCAG compliance
Senior users on tablets need at least 44×44px touch targets (WCAG 2.2).
Added min-h-[44px] flex items-center px-2 to the discard button.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
b61cfa081f test(bulk-upload): add positive navigation assertion for successful save
The error-path test (goto not called on failure) had no matching positive
assertion. Added: save() navigates to /documents when all chunks succeed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
d914385afc fix(bulk-upload): correct stale DocumentBatchMetadataDTO type in api.ts
Generated type had tags?: string but Java DTO declares List<String> tagNames.
Corrected to tagNames?: string[] to match the backend contract.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
6cdfc1f6a3 fix(bulk-upload): announce error chip status to screen readers
The ! indicator was aria-hidden with no sr-only fallback, making failed
uploads invisible to assistive technology. Added sr-only span with
bulk_file_error_chip_label before the visual indicator.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
ed6a2fb56f test(bulk-upload): fix ScopeCard spec assertions to match actual component classes
/brand-mint/ never matched (component uses border-accent bg-accent-bg);
companion test also updated to assert the meaningful negative.
getByText('5') fixed to exact:true to avoid strict-mode ambiguity.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
58545876cd fix(bulk-upload): accessibility improvements and fetch comment
- BulkDropZone: link description <p> to drop zone region via aria-describedby
- UploadSaveBar: add explicit aria-valuenow/aria-valuemin/aria-valuemax to
  <progress> element for consistent screen reader support across browsers
- FileSwitcherStrip: add non-color error indicator (red !) to error chips so
  error state is not communicated by color alone (WCAG 1.4.1)
- BulkDocumentEditLayout: comment explaining why raw fetch is used instead of
  a SvelteKit form action (chunked FormData with per-chunk progress tracking)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
687ebf495d fix(bulk-upload): match error chips by filename, not by chunk position
save() was marking the first N files in a chunk as errored (where N = the
error count returned by the backend), but the backend errors are keyed by
filename. A failure for file[2] would incorrectly mark file[0] as the error.

Now builds a Set of error filenames and matches chunk entries by file.name.
Test added: save marks only the file whose filename matches the backend error.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
bc10f2af06 fix(i18n): remove orphaned merge conflict markers from message files
All three message files had a bare `<<<<<<< HEAD` at line 814 with no
corresponding separator or closing marker, making them invalid JSON and
breaking the Paraglide build.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
0bfd342190 test(bulk-upload): add unit tests for storeDocumentWithBatchMetadata
Covers four behaviours of applyBatchMetadata that had no coverage:
title applied by list index, sender resolved via PersonService,
tags applied via updateDocumentTags, and title left unchanged when
the fileIndex exceeds the titles list length.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
1973f88e56 fix(bulk-upload): truncate long chip titles with tooltip in FileSwitcherStrip
Long filenames caused chips to overflow the strip. Added max-w-[8rem]
and truncate on the title span, plus a title attribute for full text
on hover.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
9f044f429c fix(bulk-upload): enlarge scroll button touch targets to 44×44px
Prev/next scroll buttons were 24×20px, below the WCAG 2.2 SC 2.5.5
minimum of 44×44px. Changed to h-[44px] w-[44px].

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
7ad5e35fd6 fix(bulk-upload): populate aria-live region with active file title
The sr-only aria-live div was always empty, so screen readers never
announced file switches. Derived activeAnnouncement from the active
entry and bound it to the div's text content.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
e7afed5ac3 fix(bulk-upload): add aria-label to progress bar in UploadSaveBar
<progress> had no accessible name, failing WCAG 1.3.1 and 4.1.2.
Labels it with the already-existing bulk_upload_progress i18n key.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
f48d1e3cd8 fix(bulk-upload): i18n topbar title; replace hardcoded German strings
'Neues Dokument' / 'Neue Dokumente' in BulkDocumentEditLayout topbar
bypassed Paraglide. Added bulk_title_single and bulk_title_multi keys
to de/en/es message files and switched to m.*() calls.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
fc118f7032 fix(bulk-upload): skip navigation when any chunk fails to upload
goto('/documents') fired unconditionally, discarding error chips and
leaving the user with no feedback on which files failed. Now only
navigates when hadErrors is false after all chunks complete.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
4229e952fb fix(bulk-upload): include tagNames in quick-upload metadata payload
Tags were silently dropped because the metadata object built in save()
never included a tagNames field; they never reached the backend.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
e1259215ef test(bulk-upload): add save-error and discard-all coverage to BulkDocumentEditLayout spec
- save error path: server returns non-ok → fetch is called (error handling wired)
- discard-all: N=2 → click topbar button → N=0 drop-zone restored, switcher gone
- Add data-testid="discard-all-btn" to topbar discard button for reliable selection

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
f06d034b36 fix(bulk-upload): i18n hardcoded strings in BulkDropZone and FileSwitcherStrip
- Add bulk_drop_desc, bulk_select_files, bulk_drop_zone_label, bulk_remove_file
  keys to de/en/es message files
- BulkDropZone: use m.bulk_drop_zone_label(), m.bulk_drop_desc(),
  m.bulk_select_files() — removes all hardcoded German
- FileSwitcherStrip: use m.bulk_remove_file() on × button; move aria-live
  from <ul> to a dedicated visually-hidden region above the strip (screen
  readers now announce changes without coupling the live region to the list)
- Spec: import FileEntry from component instead of re-declaring; use
  data-remove-id selector instead of hardcoded German aria-label

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
a6cd10f219 refactor(documents): extract applyBatchMetadata private helper in DocumentService
storeDocumentWithBatchMetadata was a 30-line flat method mixing file storage
with metadata hydration. The private helper makes each concern visible at a
glance.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
b8e6fe9ec9 refactor(documents): change DocumentBatchMetadataDTO.tags from String to List<String> tagNames
Replaces comma-delimited String with a proper JSON array field — callers no
longer need to pre-serialise. Service drops the split/trim/filter step and
passes tagNames directly to updateDocumentTags().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
763f1990cd refactor(documents): move batch validation from controller into DocumentService
Validation guards (BATCH_TOO_LARGE, titles > files) are domain rules and
belong in the service where they can be unit-tested without the HTTP layer.
Controller now delegates to documentService.validateBatch().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
ca62f50921 fix(forms): remove autofocus from WhoWhenSection entirely
The autofocus prop was added conditionally but still triggered on the
bulk-upload page. Removing it completely — callers that need focus
management can handle it independently.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
61f84a86ac fix(forms): apply py-3 to location input for consistent 44px height
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
0eb5c95c6c fix(forms): raise date and sender field height to match receiver (44px)
PersonMultiSelect naturally renders at 44px due to nested padding (outer p-2 + inner p-1).
Apply py-3 px-2 to the date input and PersonTypeahead default mode so all three fields
align visually.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
d662635392 fix(PersonTypeahead): match height and border-radius of other form inputs
Default mode was text-base (16px) and rounded-md — date field uses text-sm
(14px) and rounded. Aligning these makes Sender/Date/Receiver rows consistent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
b00be2548c fix(PersonMultiSelect): align height and focus ring with other form inputs
min-h-[42px] → min-h-[38px] to match p-2 text-sm input height.
Add shadow-sm (was missing vs date/sender inputs).
focus-within:ring-1 ring-ink → focus-within:ring-2 ring-focus-ring to match
the focus style used consistently across all other form inputs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
01a8654347 fix(bulk-upload): no layout shift, no autofocus on date field
Replace JS navHeight measurement with CSS var(--header-height) so the fixed
panel renders in its final position on first paint — no onMount shift.

Add autofocus prop to WhoWhenSection (default true, preserves document-edit
behaviour) and pass autofocus={false} from BulkDocumentEditLayout so the date
field does not steal focus before the user has even dropped any files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
c1b221412f fix(bulk-upload): PDF-only file acceptance
Drop non-PDF accept types from file input and update format hint strings
in all three languages. JPEG/PNG/TIFF were never officially supported.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
76c14ea604 fix(bulk-upload): form layout polish and drop zone sizing
- Drop zone box doubled: max-w-xl, larger icon (80px), bigger padding and text
- Title field wrapped in its own card (matches WhoWhenSection/DescriptionSection)
- Removed double-wrapping outer card around WhoWhenSection + DescriptionSection
- Added space-y-4 between form sections for consistent breathing room
- ScopeCard per-file label: text-accent → text-primary for legible contrast in light theme

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
539842e849 fix(bulk-upload): spec-compliant split-panel layout with local PDF preview
Rewrites BulkDocumentEditLayout to match the spec exactly:
- Fixed viewport layout (same as DocumentEditLayout) filling viewport below nav
- Split panel visible in all states (N=0/1/≥2) — was fullscreen dark drop zone
- N=0: centered drop-zone-box in left panel; shared form visible but greyed out
- N≥1: real PDF preview via URL.createObjectURL (no server upload required)
- N≥2: FileSwitcherStrip at bottom of left panel; count pill + discard in topbar
- FileEntry gains previewUrl; blob URLs created on add, revoked on remove/destroy
- save() checks response.ok and marks failed files with status: 'error'
- BulkDropZone redesigned: spec-accurate box with circular mint icon, serif title
- FileSwitcherStrip: number badges, arrows, keyboard nav via data-chip-id selector
- ScopeCard, UploadSaveBar: hardcoded German replaced with Paraglide i18n keys
- +page.svelte simplified to bare component render (layout is self-contained)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
ef7a51fe30 chore(api): regenerate types — adds DocumentBatchMetadataDTO
New type from the bulk-upload metadata part added in #317.
Generated from backend running with --spring.profiles.active=dev.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
ec17cb123a feat(bulk-upload): wire /documents/new to BulkDocumentEditLayout
Replaces the single-file form-action flow with BulkDocumentEditLayout,
enabling multi-file drag-and-drop upload with local preview, per-file
title editing, and shared metadata. Server load function unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
801470093d feat(bulk-upload): add BulkDocumentEditLayout component with save handler
State-owner for the bulk upload flow:
- N=0: full-panel BulkDropZone
- N=1: title + shared metadata (no switcher/scope cards)
- N≥2: FileSwitcherStrip + per-file ScopeCard + shared ScopeCard
Save handler chunks files at 10/request, POSTs to /api/documents/quick-upload
with typed metadata JSON part, tracks progress, redirects to /documents.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
af6ba6a9cc feat(bulk-upload): add UploadSaveBar component + fix bulk_save_cta message
Save bar with sticky positioning, a determinate progress bar while
uploading chunks, plural save CTA, and a destructive discard link.
Replaces broken ICU plural in bulk_save_cta with two-key approach
(bulk_save_cta_one / bulk_save_cta) since Paraglide 2.5 does not support
ICU plural syntax.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
9acd5ec617 feat(bulk-upload): add ScopeCard component
Card container with two variants: per-file (mint tint) and shared (neutral
with file-count badge). Used to visually separate per-file vs shared
metadata sections in the bulk upload layout.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
29a44b3cd1 feat(bulk-upload): add FileSwitcherStrip component
Horizontal chip strip for switching between files in a bulk upload session.
Supports keyboard navigation (arrow keys cycle within the strip), error state
chips, and onSelect/onRemove callbacks.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
5fe289b06b feat(bulk-upload): add BulkDropZone component
Full-panel drop target that supports multi-file selection via drag-and-drop
or file picker. Fires onFilesAdded callback with the full File array.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
f76af8c678 feat(bulk-upload): add bulkTitleFromFilename utility
Converts a raw filename into a human-readable title candidate by
stripping the extension and replacing underscore/hyphen runs with spaces.
Reuses the existing stripExtension() helper.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
69c739c6e3 feat(i18n): add BATCH_TOO_LARGE error code + 16 bulk-upload Paraglide keys
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
43cf022f05 feat(documents): extend quick-upload with optional batch metadata part
- Add DocumentBatchMetadataDTO (titles, senderId, receiverIds, documentDate, location, tags, metadataComplete)
- Add BATCH_TOO_LARGE to ErrorCode
- Extend quickUpload to accept optional @RequestPart("metadata"); dispatches to storeDocumentWithBatchMetadata when present
- Cap batch at 50 files/request; reject 400 when titles.size > files.size
- Add DocumentService.storeDocumentWithBatchMetadata applying shared fields + index-based titles to both created and updated docs
- Raise max-request-size to 500MB (10-file chunk at max per-file size)
- Add structured SLF4J logging for every quickUpload call

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:22 +02:00
Marcel
48d034dcb8 fix(transcribe-coach): propagate hover from 44px button group to inner span
Some checks failed
CI / Backend Unit Tests (push) Has been cancelled
CI / OCR Service Tests (push) Has been cancelled
CI / Unit & Component Tests (push) Has started running
hover: on the <span> only fired on the 20×20px visual circle, not the
full 44×44px touch target. Add `group` + `focus-visible:ring-*` to the
outer button; switch to `group-hover:` on the inner span so the visual
response covers the entire interactive area.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:02 +02:00
Marcel
c335ddd686 test(e2e): add training footer positive-case test and fix broken selectors
- createEmptyDocument now uploads a minimal PDF so the Transkribieren
  button is rendered (requires isPdf = true in DocumentTopBar)
- add 'Transcribe coach — with blocks' describe: seeds a block via API,
  waits for blocks to settle in read mode, switches to edit, confirms
  'Für Training vormerken' is visible
- fix dark-theme axe test: ThemeToggle uses aria-label 'dark mode',
  not the previous /Farbmodus|theme/ regex

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:02 +02:00
Marcel
7830a749a0 docs(richtlinien): shorten prerender comment to essentials
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:02 +02:00
Marcel
5b7c37391c test(HelpPopover): use userEvent.keyboard for Enter/Space tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:02 +02:00
Marcel
ce72b07197 test(e2e): fix locators, add print assertion, cleanup, remove redundant emulateMedia
- help-popover: replace broad button[aria-expanded] with specific
  getByLabel('Lese- und Bearbeitungsmodus'); update role="tooltip" →
  role="region"; add afterAll doc cleanup (Sara/Tobias)
- richtlinien: assert .new-tab spans are hidden in print media — the
  existing test only checked .app-nav (Sara)
- transcribe-coach: remove 4× redundant page.emulateMedia({reducedMotion})
  calls — playwright.config.ts already sets reducedMotion: 'reduce' globally;
  add afterAll doc cleanup (Tobias)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:02 +02:00
Marcel
505804c893 chore(i18n): remove dead transcription_empty_draw_hint key
The key was orphaned when TranscriptionEditView's empty state was replaced
by TranscribeCoachEmptyState. Removed from de/en/es to avoid accumulating
unreferenced strings. (Felix)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:02 +02:00
Marcel
67421a4c0c docs(richtlinien): document why prerender=true is auth-safe
handleAuth in hooks.server.ts is in the sequence() chain and redirects
unauthenticated users at runtime regardless of prerender. Adding a comment
so the next reader doesn't mistake this for a security hole. (Markus/Nora)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:02 +02:00
Marcel
0ea0df4f72 fix(richtlinien): <main> landmark + closing card h2 → h3
- Wrap page content in <main> so AT users can jump to main content (Nora)
- Closing card "Fehlt eine Regel?" was <h2> after two existing <h2> siblings
  but styled like a card title, not a section label; downgrade to <h3> to
  fix the heading hierarchy (Sara/Leonie)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:02 +02:00
Marcel
077f5c85df fix(TranscribeCoachEmptyState): Tailwind grid instead of inline styles; step aria-labels
- Replace style="grid-template-columns: 34px 1fr; align-items: start;"
  with Tailwind grid-cols-[34px_1fr] items-start (Felix: inline styles)
- Add aria-label="Schritt N von 3" on each <li> so screen readers announce
  step position when the numeric badge is aria-hidden (Nora/Sara)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:02 +02:00
Marcel
018e272a3b fix(RichtlinienRuleCard): bg-[#FAF8F1] → bg-parchment design token
Raw hex bypassed the token system and wouldn't remap in dark mode.
Now uses --color-parchment which has a proper dark-mode counterpart.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:02 +02:00
Marcel
0c4a0ead7b fix(TranscribeDragDemo): reactive prefersReducedMotion + bg-parchment token
- Replace one-shot $derived(.matches) snapshot with $state + addEventListener
  so the static/animated branch reacts when the user toggles OS reduced-motion
  at runtime (Felix: non-reactive media query)
- Replace bg-[#FAF8F1] raw hex with bg-parchment design token so the SVG
  background remaps correctly in dark mode (Felix/Markus)

Also update TranscriptionPanelHeader.svelte.test.ts to expect role="region"
after the HelpPopover ARIA fix.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:02 +02:00
Marcel
82b12d4383 fix(HelpPopover): role=region, 44px touch target, counter-based ID
- role="tooltip" → role="region" + aria-label={label}: tooltip semantics
  are wrong for a click-triggered panel (Nora/Sara)
- expand button to 44×44px with inner visual <span>: WCAG 2.5.8 touch
  target for 60+ transcriber audience (Sara/Leonie)
- replace Math.random() with module-level counter: SSR/hydration mismatch
  when server and client generate different IDs (Felix)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:02 +02:00
Marcel
01758e8e00 feat(tokens): add --color-parchment design token for warm example-block surfaces
Adds --c-parchment (#faf8f1 light / #041828 dark) to :root and both
dark-mode blocks, exposed as --color-parchment via @theme inline.
Prerequisite for replacing bg-[#FAF8F1] raw-hex in RichtlinienRuleCard
and TranscribeDragDemo.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:24:02 +02:00
Marcel
c3fac5b0ad feat(#320): guided empty state + Kurrent primer for first-time transcribers
Some checks failed
CI / Unit & Component Tests (push) Failing after 4m24s
CI / OCR Service Tests (push) Successful in 3m11s
CI / Backend Unit Tests (push) Failing after 3m33s
- Three-step coach card replaces Transcribe panel empty state (edit mode)
- TranscribeDragDemo: 5-second SMIL animation, static final frame for prefers-reduced-motion
- HelpPopover reusable primitive with Esc/outside-click/focus-return
- (?) help chip in TranscriptionPanelHeader next to Read/Edit toggle
- Copy pass: markieren → einrahmen in transcription_next_block_cta
- New route /hilfe/transkription (prerendered, auth-required) with 5 RichtlinienRuleCard instances, 4 Klärung chips, closing card, @media print styles
- 34 new i18n keys across de/en/es
- E2E specs: transcribe-coach, richtlinien (axe + print), help-popover; reducedMotion: 'reduce' project-wide default

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 21:42:29 +02:00
Marcel
03b180fe88 test(e2e): add transcribe-coach, richtlinien, and help-popover E2E specs; reducedMotion global default
Some checks failed
CI / Unit & Component Tests (push) Failing after 4m29s
CI / OCR Service Tests (push) Successful in 55s
CI / Backend Unit Tests (push) Failing after 3m16s
CI / Unit & Component Tests (pull_request) Failing after 3m3s
CI / OCR Service Tests (pull_request) Successful in 39s
CI / Backend Unit Tests (pull_request) Failing after 3m4s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 21:39:03 +02:00
Marcel
b234db0472 feat(richtlinien): add /hilfe/transkription page with RichtlinienRuleCard
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 21:28:25 +02:00
Marcel
7c3a8e7651 feat(transcribe): add HelpPopover primitive and wire (?) chip into panel header
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 21:19:48 +02:00
Marcel
7fb9d74515 feat(transcribe): copy pass markieren→einrahmen in transcription_next_block_cta
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 21:08:40 +02:00
Marcel
dff203d526 feat(transcribe): wire coach into TranscriptionEditView, hide training footer when empty
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 21:06:50 +02:00
Marcel
86584a53a8 feat(transcribe): add TranscribeCoachEmptyState and TranscribeDragDemo components
New coach card replaces the icon+sentence empty state in the Transcribe
panel (edit mode). Three-step guide with 5-s SMIL drawing animation in
step 1 only. Animation freezes at the final frame when
prefers-reduced-motion is active. Footer links to Wikipedia Kurrent and
the Richtlinien page open in new tabs with visible '(öffnet in neuem Tab)'
annotations. 34 new i18n keys in de/en/es.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 21:00:01 +02:00
Marcel
1d5219eac4 docs(specs): add Transkriptions-Richtlinien spec for #320
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m59s
CI / OCR Service Tests (push) Successful in 38s
CI / Backend Unit Tests (push) Failing after 2m58s
Final UI/UX spec for the /hilfe/transkription page referenced from
the Transcribe panel coach card. Card-grid layout with per-rule
Beispiel boxes, Wikipedia info-card, "Noch in Klärung" strip, and
closing invitation. Includes impl-ref tables, Paraglide keys for
de/en/es, print styles, and Gherkin acceptance criteria.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 18:47:47 +02:00
Marcel
6e021fb23a fix(briefwechsel): repair 500 by consuming backend thumbnailUrl directly
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m46s
CI / OCR Service Tests (push) Successful in 29s
CI / Backend Unit Tests (push) Failing after 2m56s
ConversationThumbnail still imported the `$lib/thumbnails` helper that
a02f6cdc deleted, so every SSR render of /briefwechsel crashed with
"Cannot find module '$lib/thumbnails'". Finish that refactor by reading
`doc.thumbnailUrl` straight off the Document DTO (same shape
DocumentThumbnail already uses), and update the spec fixtures to match.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 13:27:19 +02:00
Marcel
bdac5e42ad test(search): integration test covers paged search against real Postgres — address @saraholt
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m15s
CI / OCR Service Tests (push) Successful in 29s
CI / Backend Unit Tests (push) Has been cancelled
Seeds 120 UPLOADED docs with a deterministic date spread and runs
DocumentService.searchDocuments against a Testcontainers Postgres, not
a Mockito mock. Five cases:

  1. First page returns exactly page_size items + correct totalElements
  2. Last partial page returns the tail slice (offset 100 → 20 items)
  3. Page beyond last returns empty content, totalElements still 120
  4. SENDER sort path slices in-memory + reports correct total
  5. Different pages return disjoint document id sets

Closes the integration-coverage gap between the Mockito unit tests and
the full Spec→Pageable→Page→DTO path that unit tests can't exercise.
Runs in ~87 s against the shared Testcontainers instance. (#316)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 13:20:24 +02:00
Marcel
18b88672ec fix(pagination): bound controls render as aria-hidden spans — address @leonievoss
<a aria-disabled="true"> is the documented pattern but screen readers
still announce "Previous, link, disabled" on pagination bounds — noise
users don't need because the disabled state is purely visual. Switching
to <span aria-hidden="true"> removes the bound control from the AT tree
entirely (Leonie's recommendation). Visual parity preserved via a
disabledBase Tailwind class (same layout + cursor-not-allowed + opacity-40).

Tests updated: "disabled prev/next" assertions now check for aria-hidden
and no href — the active-state href/aria-current assertions are
unchanged. (#316)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 13:20:24 +02:00
Marcel
8fa061187e refactor(documents): extract buildSearchParams — address @felixbrandt
triggerSearch (local state, filter change) and buildPageHref (server data,
page nav) were each iterating over the same ~10 filter params. Any new
filter would have had to land in two places. buildSearchParams is now the
single source of truth for which params the /documents URL understands;
both callers just pass their snapshot and an optional targetPage. (#316)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 13:20:24 +02:00
Marcel
610915b2a2 refactor(test): extract UNPAGED Pageable constant — address @felixbrandt + @saraholt
PageRequest.of(0, 10_000) was inlined at ~12 sites across DocumentServiceTest
and DocumentServiceSortTest as an "effectively unpaged" sentinel for tests
that don't care about paging. Extracted to a named constant on each class
so the intent is visible at each callsite and we don't risk copy-paste
drift of the magic number. No behaviour change. (#316)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 13:20:24 +02:00
Marcel
78ac5d663d feat(documents): paginate search with a Pagination control
Frontend side of the /documents pagination work. The page.server.ts load
reads ?page= from the URL, forwards page+size=50 to the backend, and
exposes the new totalElements/pageNumber/pageSize/totalPages fields on
`data`. +page.svelte renders a <Pagination> component below the result
list; buildPageHref preserves every filter param and only updates page.
The existing triggerSearch debounce flow intentionally drops `page`
when any filter changes, so filter edits reset to page 0 automatically.

<Pagination> uses plain <a href> links (not goto) so SvelteKit's default
scroll restoration scrolls new pages to the top — the expected senior-UX
behaviour. Decorative chevrons wrapped in aria-hidden spans, 44px touch
targets, focus-visible ring, stacks vertically under 640px. The control
hides itself when totalPages ≤ 1.

Test coverage: 9 cases on Pagination (label, aria-current, prev/next
enable/disable, makeHref invocation, decorative chevron, touch target),
plus a filter-reset assertion on +page.svelte (page 5 → edit q →
goto URL must drop page=). Adds i18n keys in de/en/es. Manual edit to
api.ts pending a post-merge npm run generate:api against a rebuilt
dev backend. (#315)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 13:20:24 +02:00
Marcel
826c0827dc test(search): lock pagination behaviour and @Validated rejection
Adds 5 dedicated controller cases — paging fields exposed on the JSON,
rejections for size>100 / size<1 / page<0 / page>100000, and a
captor assertion that the built PageRequest is forwarded to the service.
The size>100 case is the load-bearing guard on @Validated at
DocumentController — removing the annotation silently reopens the DoS
window this PR is meant to close.

Adds 5 service cases — fast path uses findAll(Spec, Pageable) (not Sort),
propagates page+size to the DB, carries totalElements/totalPages/
pageNumber/pageSize back on the result, and for SENDER sort slices in
memory and reports the pre-slice total. Page-beyond-last returns empty
content with a correct totalElements (JPA edge case). (#315)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 13:20:24 +02:00
Marcel
7a75ffed76 feat(search): DocumentService.searchDocuments takes Pageable and slices
Fast path (DATE/TITLE/UPLOAD_DATE) pushes sort + paging into the DB via
findAll(Specification, PageRequest) and enriches only the returned slice
— 30× cheaper than enriching all 1500 matches when the user is only
going to see 50. In-memory sort paths (SENDER/RECEIVER/RELEVANCE) keep
their LEFT JOIN-friendly sort but now slice in-memory too, so enrichment
still runs against the page slice only.

Controller passes PageRequest.of(page, size) built from @RequestParam
values. Plan-level "add @Validated" prerequisite comes in the next commit.

All existing tests updated mechanically to pass a pageable argument
(PageRequest.of(0, 10_000) as an "effectively unpaged" sentinel). Stubs
that previously matched findAll(Specification, Sort) for the fast path
now match findAll(Specification, Pageable) with PageImpl<>.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 13:20:24 +02:00
Marcel
1299bd5938 feat(search-result): extend DocumentSearchResult with pageNumber/pageSize/totalPages
Rename `total` → `totalElements` for Spring-Page parity and add three new
required paging fields: pageNumber, pageSize, totalPages. Adds a `paged(
slice, pageable, totalElements)` factory alongside the existing single-page
`of(list)` shortcut. Enables offset pagination of /documents search (#315).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 13:20:24 +02:00
Marcel
8f28a99e00 docs(specs): bulk upload split-panel spec + concept exploration
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m7s
CI / OCR Service Tests (push) Successful in 28s
CI / Backend Unit Tests (push) Failing after 2m51s
Adds two specs for extending issue #294 with bulk uploads:

- bulk-upload-concepts.html — three concepts (stack, split-panel
  with file switcher, progressive accordion) with a decision
  matrix and the Concept B recommendation.
- bulk-upload-split-panel-spec.html — refined final spec for
  Concept B. Covers all three states (N=0 empty · N=1 single ·
  N≥2 multi) across 320 / 375 / 768 / 1280 viewports in both
  light and dark mode, using the real tokens from layout.css.
  Includes impl-ref tables for every new surface, Paraglide keys
  in de/en/es, component tree, and backend contract.

The polymorphic-state model means /documents/new is a single
route: N=1 is byte-identical to #294, N=0 shows a whole-panel
drop zone with bulk-first copy, N≥2 grows a file-switcher strip
under the PDF preview plus a two-card form split.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 10:31:42 +02:00
Marcel
7007491d8c style(dashboard): address @leonievoss — scale fallback icon to match larger container
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 3m26s
CI / OCR Service Tests (pull_request) Successful in 35s
CI / Backend Unit Tests (pull_request) Failing after 3m4s
CI / Unit & Component Tests (push) Failing after 3m7s
CI / OCR Service Tests (push) Successful in 36s
CI / Backend Unit Tests (push) Failing after 3m17s
h-16 w-16 looked undersized in the 180×252 strip container (~25% of
the height). h-24 w-24 gives ~38% visual weight, matching the ratio
DocumentThumbnail uses for its lg (120×168) fallback (#309).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 07:26:23 +02:00
Marcel
629f0183f7 test(document): address @saraholt — lock JSON wire contract for thumbnailUrl
Prior coverage only exercised getThumbnailUrl() as a Java method call.
The new case serialises via ObjectMapper and asserts the resulting JSON
contains "thumbnailUrl":"..." so we catch silent breakages in the wire
contract (getter rename, @JsonIgnore, visibility drop) — not just
regressions in the method's return value (#309).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 07:26:23 +02:00
Marcel
72cd6f5bbc feat(dashboard): fall back to document-text heroicon when no thumbnail yet
Uses the same heroicon as DocumentThumbnail so the "no thumbnail yet"
signal reads identically across the app: one shape, one meaning. The
parchment SVG still lives on in the fully-empty state (no resume doc
at all), where it represents a different thing — we removed it only
from the "document exists, thumbnail not generated yet" branch (#309).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 07:26:23 +02:00
Marcel
1d44bbb1bd feat(dashboard): render real document thumbnail in resume strip
Replaces the generic parchment SVG placeholder with an <img> pointing at
the backend's thumbnail endpoint when the document has one. The 180×252
container matches DocumentThumbnail's 5:7 A4 convention so the
dashboard tile sits visually next to the list/person-sublist tiles
instead of looking squatter than they do. dark:mix-blend-multiply keeps
paper scans from glaring on a dark page background (#309).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 07:26:23 +02:00
Marcel
a02f6cdcd7 refactor(thumbnails): drop frontend URL-builder now that backend owns the convention
The helper had a single consumer (DocumentThumbnail) and its only job
was to compose what the backend's Document.getThumbnailUrl() now
produces. Deleting it locks the single-source-of-truth invariant —
there is no longer a way to build a thumbnail URL on the client (#309).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 07:26:23 +02:00
Marcel
817749889a refactor(document-thumbnail): read doc.thumbnailUrl instead of composing locally
The backend now exposes thumbnailUrl as a serialised computed property
on Document, so the component drops its dependency on the frontend
URL-builder. PersonDocumentList's inline Doc prop type follows the
same shift (#309).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 07:26:23 +02:00
Marcel
a8b9133b80 chore(api): regenerate Document type with thumbnailUrl field
Reflects the new @JsonProperty getter on Document. Kept as a minimal
manual edit rather than a full regen because the running dev backend
belongs to the main workspace and swapping JARs there would be a
side effect on a parallel worktree's state. `npm run generate:api`
will converge on the same shape.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 07:26:23 +02:00
Marcel
510ab1d2d5 feat(dashboard): populate resume thumbnailUrl from Document
DashboardService now reads the URL from the Document's computed getter
instead of passing null, so the resume strip can display the real
thumbnail of whatever the user was last working on (#309).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 07:25:50 +02:00
Marcel
ad999c47ea feat(document): expose thumbnailUrl to JSON serialisation
@JsonProperty makes the computed getter part of every Document response
Jackson produces, so any DTO returning a Document automatically carries
the thumbnail URL without per-controller plumbing. The accompanying
comment warns future readers that the cache-buster is load-bearing
for the endpoint's `immutable` cache header (CWE-525) (#309).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 07:25:50 +02:00
Marcel
9862a51ac7 feat(document): getThumbnailUrl appends URL-encoded timestamp as cache-buster
Matches the shape the frontend previously built via
encodeURIComponent(thumbnailGeneratedAt), so the backend is now the
single source of truth for the thumbnail URL convention (#309).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 07:25:50 +02:00
Marcel
df260d5c64 feat(document): getThumbnailUrl composes /api/documents/{id}/thumbnail when key present
The no-cache-buster branch covers documents whose thumbnail key is set
but whose thumbnailGeneratedAt is still null — which only happens in
the narrow window between the key being persisted and the async worker
stamping the timestamp (#309).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 07:25:50 +02:00
Marcel
096f66eb15 test(document): getThumbnailUrl returns null when thumbnailKey is null
First TDD step for centralising the thumbnail URL convention on the
Document entity (#309). Adds a stub getter returning null and a test
that locks the "no key → no URL" branch.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 07:25:50 +02:00
Marcel
0b33f323ee feat(briefwechsel): restore direction arrow next to row title
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m45s
CI / OCR Service Tests (push) Successful in 31s
CI / Backend Unit Tests (push) Failing after 2m56s
PR #311 dropped the right/left arrow icons that signalled whether a
letter was sent or received. Readers who don't decode the colored
left border (new users, color-blind users, users at a glance) had
no visual cue for direction. Restore a 20×20 arrow inline with the
title — right-arrow for outgoing, left-arrow for incoming — kept
decorative (aria-hidden) since the aria-label already announces
"Gesendet:" / "Empfangen:".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 21:57:57 +02:00
Marcel
334b624063 feat(briefwechsel): bump row typography and drop relative-year chip
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m50s
CI / OCR Service Tests (push) Successful in 34s
CI / Backend Unit Tests (push) Has been cancelled
The 168px-tall thumbnail tile was dominating rows where the text
column only rendered at text-xs / text-sm — visually the right
column sat half-empty. Three changes:

- Title: text-sm → text-lg
- Summary: text-sm → text-base
- Meta + tag chips: text-xs → text-sm

And remove the "vor N Jahren" chip entirely. The documentDate
in the meta row already carries the temporal context and the
chip was adding visual noise without new information.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 21:52:23 +02:00
Marcel
503ce49ef7 refactor(briefwechsel): TagChipList defaults max to 3
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m51s
CI / OCR Service Tests (push) Successful in 30s
CI / Backend Unit Tests (push) Failing after 2m56s
Makes `max` an optional prop with default 3 — the common row-layout
case doesn't need to name the cap explicitly. ThumbnailRow's callsite
drops to `<TagChipList tags={doc.tags ?? []} />`, consistent with how
other shared components in $lib/components expose sensible defaults.

Refs #305
Fixes @leonievoss round-2 follow-up from PR review

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 21:38:56 +02:00
Marcel
f5a30c71b7 i18n(briefwechsel): ThumbnailRow direction label via Paraglide
Adds row_direction_sent / row_direction_received keys across the
three locale files (de: Gesendet/Empfangen, en: Sent/Received, es:
Enviada/Recibida) and routes ThumbnailRow's directionLabel through
Paraglide. An English or Spanish screen-reader user now hears
"Sent:" / "Enviada:" in their language, matching the DistributionBar
i18n pass.

Refs #305
Fixes @leonievoss round-2 follow-up from PR review

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 21:38:56 +02:00
Marcel
720f90299a refactor(e2e): visual spec shares seedBilateralPair + asserts person-bar
Rewires briefwechsel-rows.visual.spec.ts against the shared fixture
(seedBilateralPair + cleanupBilateralPair), adds afterAll cleanup,
and folds the conv-person-bar visibility gate into openBilateral()
so both the structural test and the snapshot block fail loudly on
a hero-state regression — matching the a11y spec's safety net.

Refs #305
Fixes @saraholt follow-ups 1 + 2 + 3 from PR round-2 review

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 21:38:56 +02:00
Marcel
0e988a9d42 refactor(e2e): extract seedBilateralPair fixture + afterAll cleanup
Lifts the three-API-call seeding (create sender, create receiver,
create document) out of briefwechsel-a11y.spec.ts and into a
dedicated fixtures module. The spec now calls seedBilateralPair()
in beforeAll and cleanupBilateralPair() in afterAll so the test
DB doesn't accrue seeded rows across reruns.

Two caveats captured in the helper docstring: the backend has no
person-delete endpoint (only the document is purged), and the
timestamped last names make leftover persons collision-free.

Refs #305
Fixes @saraholt follow-up 1 + 2 from PR round-2 review

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 21:38:56 +02:00
Marcel
8cb179a8a1 test(briefwechsel): visual spec seeds bilateral pair and asserts row structure
Extends the seeding pattern from the a11y spec: beforeAll creates two
persons + one document so the page renders the row layout. The
structural test now asserts the ConversationThumbnail tile AND the
DistributionBar are present — a regression that drops to the hero
or breaks the row wiring fails here instead of silently passing a
hero-state check.

Snapshot block stays gated on VISUAL=1 (baselines captured during
review against a seeded backend) so the structural coverage ships
immediately and the pixel-diff coverage ships once baselines land.

Refs #305
Fixes @saraholt blocker 2 from PR review

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 21:38:56 +02:00
Marcel
05c1bf750a test(briefwechsel): a11y spec seeds bilateral pair and axes the row layout
The previous version navigated to /briefwechsel with no params, which
renders the hero state — axe-core scanned the hero, not the new
ThumbnailRow / ConversationThumbnail / DistributionBar. This commit
seeds two persons + one document via the API in beforeAll, then
drives the URL with ?senderId=X&receiverId=Y so each of the
36 test runs (3 viewports × 2 themes × 2 assertions) actually scans
the intended DOM. Also asserts that conv-person-bar is visible first,
so a regression that drops the page back to hero fails explicitly
rather than silently passing an empty sweep.

Refs #305
Fixes @saraholt blocker 1 from PR review

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 21:38:56 +02:00
Marcel
a7ab5e6e69 refactor(briefwechsel): extract TagChipList from ThumbnailRow
Lifts the three-chip-plus-"+N" tag row out of ThumbnailRow into a
standalone TagChipList component so the chip cap + overflow policy
lives in one place and can be reused on other surfaces (document
detail header is a candidate). ThumbnailRow drops from 110 to ~90
lines and no longer owns tag-slicing logic — it just asks for the
list with max=3.

Behavior is byte-identical: same data-testid, same max cap, same
"+N" overflow indicator. All ThumbnailRow row-level tag tests
continue to pass against the new composition.

Refs #305
Fixes @felixbrandt suggestion 1 from PR review

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 21:38:56 +02:00
Marcel
24b2dc0460 refactor(thumbnails): pack key + aspect + pageCount into ThumbnailResult
persistThumbnailMetadata was a four-arg method signature that mixed
three conceptually related values. Wrapping them in a private
ThumbnailResult record drops the signature to (Document, result),
mirrors the existing SourcePreview record one step earlier in the
pipeline, and keeps generate() reading as a narrative of small
named outputs rather than positional arguments.

Refs #305
Fixes @felixbrandt suggestion 2 from PR review

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 21:38:56 +02:00
Marcel
9ecf7f4dfc refactor(briefwechsel): ThumbnailRow captures now at prop binding
Defaults `now` in $props() destructure so each row instance freezes
its reference time at mount, instead of calling new Date() inside
the $derived every reactivity tick. No behavioural change — the
date math is stable across re-renders for a given row — but drops
the nullish-coalesce dance and is cleaner under Storybook-style
testing where a deterministic `now` is injected.

Refs #305
Fixes @felixbrandt suggestion 3 from PR review

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 21:38:56 +02:00
Marcel
01bfc59849 test(briefwechsel): lock future-date relative-year hiding at the row layer
relativeYearsDe already returns "" for future dates (covered in its
own spec), but the integration wiring inside ThumbnailRow was
untested. Adds a regression that a doc with documentDate in the
future produces no "vor N Jahren" or "vor weniger als 1 Jahr" chip.

Refs #305
Fixes @saraholt concern 5 from PR review

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 21:38:56 +02:00
Marcel
03616f0728 test(briefwechsel): makePerson factory + per-row tile assertion
Consolidates the hansPerson / annaPerson fixture into a makePerson()
factory matching the makeDoc convention, adds an assertion that
the bilateral list renders one ConversationThumbnail tile per
document (catches a broken {#each} keying wired around the
DistributionBar), and decouples the DistributionBar aria-label
assertion from the German locale now that i18n lands via Paraglide.

Refs #305
Fixes @saraholt concerns 3 + 4 from PR review

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 21:38:56 +02:00
Marcel
7090f9a0e0 feat(briefwechsel): ConversationThumbnail page badge legible at small sizes
Bumps the multi-page badge from text-xs (12px) / px-1.5 py-0.5 to
text-sm (14px) / px-2 py-1. Meets senior-legibility on a 320px phone
without crowding the 120-wide tile — the badge stays tucked in the
top-right corner.

Refs #305
Fixes @leonievoss senior-accessibility concern from PR review

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 21:38:56 +02:00
Marcel
d4617a96d1 i18n(briefwechsel): DistributionBar reads text + aria-label via Paraglide
Drops the hardcoded German strings ("Briefverteilung in diesem Zeitraum",
"{n} von {name}") and routes every visible + assistive-tech string
through dist_bar_aria and dist_bar_segment message keys. An English
or Spanish user now sees "from" / "de" instead of "von" both on
screen and in the aria-label their screen reader announces.

Refs #305
Fixes @leonievoss i18n concern from PR review

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 21:38:56 +02:00
Marcel
b9dda9a938 feat(briefwechsel): ThumbnailRow aria-label leads with Gesendet/Empfangen
Without this prefix, a color-blind user or screen-reader user has no
indication of correspondence direction — the colored left border is
information but not announced, and the arrow glyphs were removed in
the earlier layout pass. Prepending "Gesendet:" or "Empfangen:" to
the aria-label gives assistive-tech users the direction first so the
row identity is unambiguous even without color perception.

Refs #305
Fixes @leonievoss WCAG 1.4.1 concern from PR review

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 21:38:56 +02:00
Marcel
d6b1949c84 docs(adr): ADR-005 thumbnailAspect + pageCount alongside the thumbnail
Captures the reasoning behind persisting two scalar columns on
documents rather than deriving aspect client-side or standing up a
thumbnail_metadata table. Also documents the 1.1 landscape threshold,
the null-during-rollout state, and the ordering invariants inside
ThumbnailService.generate().

Refs #305

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 21:38:56 +02:00
Marcel
c16a9ca602 test(briefwechsel): axe sweep at 3 viewports x 2 themes
Adds a dedicated axe-core sweep for /briefwechsel so contrast or
semantic regressions on the new row layout fail independently of
the catch-all accessibility suite. Scoped to the main landmark so
shared chrome violations (if any) aren't double-reported.

Refs #305

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 21:38:56 +02:00
Marcel
30e301830a test(briefwechsel): scaffold visual-regression spec for row layout
Adds a Playwright spec gated on VISUAL=1 with one snapshot per
(mobile/tablet/desktop × light/dark) = 6 baselines. Snapshots stay
skipped in CI until the baseline set is captured and committed —
running `playwright test --update-snapshots briefwechsel-rows`
against a seeded backend generates them.

Structural check runs unconditionally so the file is wired into CI
today rather than waiting for the baseline capture step.

Refs #305

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 21:38:56 +02:00
Marcel
4b893b4808 test(briefwechsel): cover DistributionBar and fix Person fixture shape
Adds two new assertions for the extracted DistributionBar — it must
appear in bilateral mode and stay hidden in single-person mode — and
repairs the shared makeDoc fixture: the embedded Person now carries
personType + displayName so the fixture matches the regenerated
Document schema without TypeScript complaints.

Refs #305

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 21:38:56 +02:00
Marcel
df681be626 refactor(briefwechsel): ConversationTimeline renders ThumbnailRow per letter
Drops the inline row markup, arrow icons, status-dot helper, and the
otherPartyName helper that only fed it. Each visible row is now a
ThumbnailRow, which owns its own aria-label, border color, meta and
tag rendering. The year-divider and "new document" footer are
untouched — they were always intended to stay as timeline chrome.

Also widens the documents prop shape to include the summary, tags
and thumbnail metadata that ThumbnailRow consumes; the backend
already returns these fields via the Document schema so no server
change was required.

Refs #305

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 21:38:56 +02:00
Marcel
cc118ffb16 feat(briefwechsel): add ThumbnailRow for the new correspondence row layout
Combines ConversationThumbnail with a quote-styled summary, truncated
meta line, and up to three tag chips (the rest collapsed into "+N").
The colored left border tells a reader at a glance whether this
letter left or entered the perspective person's mailbox — replacing
the previous status dot + script-type icons that were too busy for
the list view. Relative-year label ("vor 76 Jahren") is derived from
documentDate so the list carries temporal context without a full
date column.

Rendering rules:
- title falls back to originalFilename when empty
- summary uses a text expression, never {@html}, so inline markup
  in the summary field is escaped (XSS regression test locks this)
- focus-visible outline + focus-within hover keep keyboard-only
  users in sync with mouse hover feedback
- aria-label always pairs title with the formatted date so screen
  readers hear both identifiers

Refs #305

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 21:38:56 +02:00
Marcel
407bfbd5f1 feat(briefwechsel): add ConversationThumbnail with aspect + page badge
Reads thumbnailAspect from the backend and swaps between a 120×168
portrait tile and a 168×120 landscape tile so postcards and photos
don't get cropped into a portrait frame. Shows a page-count badge
top-right for multi-page PDFs, and a pulsing skeleton while the
async thumbnail job hasn't run yet. URL assembly goes through the
existing thumbnailUrl helper so cache-busting stays consistent
with DocumentThumbnail.

Refs #305

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 21:38:56 +02:00
Marcel
a52d481a8e feat(relativeTime): add relativeYearsDe helper for historical letter dates
The correspondence timeline labels each row with its distance from today
("vor 86 Jahren"). Uses calendar-field math so the anniversary day
flips exactly — an ms-based 365.25d average misses by a day on leap
years. Invalid / future dates return "" so the caller can hide the
label rather than print "vor 0 Jahren".

Refs #305

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 21:38:56 +02:00
Marcel
70d813ee70 refactor(briefwechsel): ConversationTimeline consumes DistributionBar
Drops the inline bilateral-distribution markup and the short-name /
percentage helpers that only existed to feed it. ConversationTimeline
now hands senderName, receiverName, and the two counts to the shared
component and lets it own the rendering.

Refs #305

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 21:38:56 +02:00
Marcel
d99f4544d2 refactor(briefwechsel): extract bilateral DistributionBar component
Lifts the inline distribution bar out of ConversationTimeline so the
same two-tone ratio widget can be reused on other bilateral surfaces
(e.g. the person detail page). Markup/styling is byte-identical to
the inline version; only the prop interface is new.

Refs #305

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 21:38:56 +02:00
Marcel
22ce705bb0 feat(api): surface thumbnailAspect + pageCount on the Document type
Mirrors the backend entity additions so the frontend row components
can consume the aspect (portrait vs landscape tile) and the page count
(badge on the thumbnail) without any runtime guessing.

Refs #305

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 21:38:56 +02:00
Marcel
e6d55e47b1 feat(thumbnails): persist pageCount from PDDocument / 1 for images
Groups the first-page BufferedImage and the source's total page count
into a SourcePreview record so both values travel through generate()
together. PDFs get pdf.getNumberOfPages(); image uploads always get 1
(a scan is one page from the user's perspective). The page badge on
the thumbnail row uses this value to show "1 / N" for multi-page
letters without a separate round-trip.

Refs #305

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 21:38:56 +02:00
Marcel
b48533be26 feat(thumbnails): persist thumbnailAspect from source image dimensions
Computes aspect at generate-time from the loaded BufferedImage: w/h
above 1.1 → LANDSCAPE, otherwise PORTRAIT. The threshold keeps
near-square A4 scans in the portrait tile (ratio ≈ 1.0) rather than
flipping to landscape on a rounding error. Also hardens the pipeline
with an explicit dimension guard so width=0 / height=0 edge cases fail
cleanly instead of dividing by zero when the aspect is computed.

Refs #305

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 21:38:56 +02:00
Marcel
7fc517b787 test(thumbnails): lock corrupt-image + corrupt-pdf failure paths
Both cases already return FAILED via the existing catch-Exception blocks
in readSourceImage. Pinning the behavior with regression tests before
thumbnailAspect and pageCount computation is added, so a future
refactor that removes the safety net is caught at compile/test time.

Refs #305

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 21:38:56 +02:00
Marcel
8ac996f6b2 feat(documents): expose thumbnailAspect + pageCount on Document entity
Adds ThumbnailAspect enum (PORTRAIT | LANDSCAPE) and maps the two
nullable columns from V53 as JPA fields so ThumbnailService can
populate them and the API can return them unchanged to the frontend.

Refs #305

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 21:38:56 +02:00
Marcel
55557047de feat(documents): V53 add thumbnail_aspect + page_count columns
Adds two nullable metadata columns to documents, populated by
ThumbnailService when it generates the JPEG preview. Both remain null
until the existing admin backfill endpoint reruns the service.

Refs #305

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 21:38:56 +02:00
Marcel
94e976bae3 docs(specs): rework person dashboard spec around data reality
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m15s
CI / OCR Service Tests (push) Successful in 49s
CI / Backend Unit Tests (push) Failing after 3m1s
The archive has ~4 persons over 100 letters and ~90% with five or
fewer — the original spec's 851-letter default fit no one.

Redesign introduces three tiers gated on letterCount (Compact ≤ 5,
Standard 6–49, Rich ≥ 50) sharing one dashboard block: navy header +
4-cell stats strip at every non-Empty tier, with Standard appending
direction bar + top correspondents and Rich further appending
histogram + top locations + tag cloud. Backend skips expensive
aggregations for non-Rich persons; histogram and tag cloud ship
lazy-loaded.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 20:44:42 +02:00
120 changed files with 12553 additions and 1032 deletions

View File

@@ -3,6 +3,7 @@ package org.raddatz.familienarchiv.controller;
import java.io.IOException; import java.io.IOException;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
@@ -13,6 +14,18 @@ import java.util.UUID;
import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponse;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.validation.annotation.Validated;
import org.raddatz.familienarchiv.dto.BatchMetadataRequest;
import org.raddatz.familienarchiv.dto.BulkEditError;
import org.raddatz.familienarchiv.dto.BulkEditResult;
import org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO;
import org.raddatz.familienarchiv.dto.DocumentBatchSummary;
import org.raddatz.familienarchiv.dto.DocumentBulkEditDTO;
import org.raddatz.familienarchiv.dto.DocumentSearchResult; import org.raddatz.familienarchiv.dto.DocumentSearchResult;
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO; import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
import org.raddatz.familienarchiv.dto.TagOperator; import org.raddatz.familienarchiv.dto.TagOperator;
@@ -62,6 +75,7 @@ import lombok.extern.slf4j.Slf4j;
@RequestMapping("/api/documents") @RequestMapping("/api/documents")
@RequiredArgsConstructor @RequiredArgsConstructor
@Slf4j @Slf4j
@Validated
public class DocumentController { public class DocumentController {
private final DocumentService documentService; private final DocumentService documentService;
@@ -187,6 +201,7 @@ public class DocumentController {
@RequirePermission(Permission.WRITE_ALL) @RequirePermission(Permission.WRITE_ALL)
public QuickUploadResult quickUpload( public QuickUploadResult quickUpload(
@RequestPart(value = "files", required = false) List<MultipartFile> files, @RequestPart(value = "files", required = false) List<MultipartFile> files,
@RequestPart(value = "metadata", required = false) DocumentBatchMetadataDTO metadata,
Authentication authentication) { Authentication authentication) {
List<Document> created = new ArrayList<>(); List<Document> created = new ArrayList<>();
List<Document> updated = new ArrayList<>(); List<Document> updated = new ArrayList<>();
@@ -196,14 +211,21 @@ public class DocumentController {
return new QuickUploadResult(created, updated, errors); return new QuickUploadResult(created, updated, errors);
} }
documentService.validateBatch(files.size(), metadata);
UUID actorId = requireUserId(authentication); UUID actorId = requireUserId(authentication);
for (MultipartFile file : files) { long totalBytes = files.stream().mapToLong(MultipartFile::getSize).sum();
for (int i = 0; i < files.size(); i++) {
MultipartFile file = files.get(i);
if (!ALLOWED_CONTENT_TYPES.contains(file.getContentType())) { if (!ALLOWED_CONTENT_TYPES.contains(file.getContentType())) {
errors.add(new UploadError(file.getOriginalFilename(), "UNSUPPORTED_FILE_TYPE")); errors.add(new UploadError(file.getOriginalFilename(), "UNSUPPORTED_FILE_TYPE"));
continue; continue;
} }
try { try {
DocumentService.StoreResult result = documentService.storeDocument(file, actorId); DocumentService.StoreResult result = metadata != null
? documentService.storeDocumentWithBatchMetadata(file, metadata, i, actorId)
: documentService.storeDocument(file, actorId);
if (result.isNew()) { if (result.isNew()) {
created.add(result.document()); created.add(result.document());
} else { } else {
@@ -215,9 +237,107 @@ public class DocumentController {
} }
} }
log.info("quickUpload actor={} files={} totalBytes={} withMetadata={} created={} updated={} errors={}",
actorId, files.size(), totalBytes, metadata != null,
created.size(), updated.size(), errors.size());
return new QuickUploadResult(created, updated, errors); return new QuickUploadResult(created, updated, errors);
} }
// --- BULK EDIT ---
private static final int BULK_EDIT_MAX_IDS = 500;
/** Hard cap for {@code GET /api/documents/ids}: prevents an unfiltered
* call from materialising the entire {@code documents} table into JSON.
* Generous enough for real-world "Alle X editieren" against the family
* archive's bounded scale (~1500 docs today, expected growth to ~5k). */
private static final int BULK_EDIT_FILTER_MAX_IDS = 5000;
@PatchMapping("/bulk")
@RequirePermission(Permission.WRITE_ALL)
public BulkEditResult patchBulk(
@RequestBody @Valid DocumentBulkEditDTO dto,
Authentication authentication) {
if (dto.getDocumentIds() == null || dto.getDocumentIds().isEmpty()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "documentIds is required");
}
if (dto.getDocumentIds().size() > BULK_EDIT_MAX_IDS) {
throw DomainException.badRequest(ErrorCode.BULK_EDIT_TOO_MANY_IDS,
"Maximum " + BULK_EDIT_MAX_IDS + " documents per request, got: " + dto.getDocumentIds().size());
}
UUID actorId = requireUserId(authentication);
int updated = 0;
List<BulkEditError> errors = new ArrayList<>();
// Dedupe duplicate document IDs while preserving submission order. A
// double-click on "Alle X editieren" would otherwise hit each document
// twice and inflate the `updated` count returned to the user.
LinkedHashSet<UUID> uniqueIds = new LinkedHashSet<>(dto.getDocumentIds());
for (UUID id : uniqueIds) {
try {
documentService.applyBulkEditToDocument(id, dto, actorId);
updated++;
} catch (DomainException e) {
errors.add(new BulkEditError(id, sanitizeForLog(e.getMessage())));
} catch (Exception e) {
errors.add(new BulkEditError(id, "Internal error"));
log.warn("Bulk edit failed for document {}: {}", id, sanitizeForLog(e.getMessage()));
}
}
log.info("bulkEdit actor={} documentIds={} unique={} updated={} errors={}",
actorId, dto.getDocumentIds().size(), uniqueIds.size(), updated, errors.size());
return new BulkEditResult(updated, errors);
}
/** CRLF strip for any log line interpolating a free-form string (e.g.
* {@link Throwable#getMessage()}). Defends against CWE-117 log injection. */
private static String sanitizeForLog(String s) {
return s == null ? null : s.replaceAll("[\\r\\n]", "_");
}
@GetMapping("/ids")
@RequirePermission(Permission.WRITE_ALL)
public List<UUID> getDocumentIds(
@RequestParam(required = false) String q,
@RequestParam(required = false) LocalDate from,
@RequestParam(required = false) LocalDate to,
@RequestParam(required = false) UUID senderId,
@RequestParam(required = false) UUID receiverId,
@RequestParam(required = false, name = "tag") List<String> tags,
@RequestParam(required = false) String tagQ,
@RequestParam(required = false) DocumentStatus status,
@RequestParam(required = false) String tagOp,
Authentication authentication) {
TagOperator operator = "OR".equalsIgnoreCase(tagOp) ? TagOperator.OR : TagOperator.AND;
List<UUID> ids = documentService.findIdsForFilter(q, from, to, senderId, receiverId, tags, tagQ, status, operator);
if (ids.size() > BULK_EDIT_FILTER_MAX_IDS) {
throw DomainException.badRequest(ErrorCode.BULK_EDIT_TOO_MANY_IDS,
"Filter matches " + ids.size() + " documents — refine filter (max " + BULK_EDIT_FILTER_MAX_IDS + ")");
}
UUID actorId = requireUserId(authentication);
log.info("documentIds actor={} matched={}", actorId, ids.size());
return ids;
}
@PostMapping(value = "/batch-metadata", consumes = MediaType.APPLICATION_JSON_VALUE)
@RequirePermission(Permission.READ_ALL)
public List<DocumentBatchSummary> batchMetadata(@RequestBody @Valid BatchMetadataRequest request, Authentication authentication) {
if (request == null || request.ids() == null || request.ids().isEmpty()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "ids is required");
}
if (request.ids().size() > BULK_EDIT_MAX_IDS) {
throw DomainException.badRequest(ErrorCode.BULK_EDIT_TOO_MANY_IDS,
"Maximum " + BULK_EDIT_MAX_IDS + " ids per request, got: " + request.ids().size());
}
UUID actorId = requireUserId(authentication);
log.info("batchMetadata actor={} ids={}", actorId, request.ids().size());
return documentService.batchMetadata(request.ids());
}
@GetMapping("/incomplete-count") @GetMapping("/incomplete-count")
@RequirePermission(Permission.WRITE_ALL) @RequirePermission(Permission.WRITE_ALL)
public Map<String, Long> getIncompleteCount() { public Map<String, Long> getIncompleteCount() {
@@ -252,14 +372,20 @@ public class DocumentController {
@Parameter(description = "Filter by document status") @RequestParam(required = false) DocumentStatus status, @Parameter(description = "Filter by document status") @RequestParam(required = false) DocumentStatus status,
@Parameter(description = "Sort field") @RequestParam(required = false) DocumentSort sort, @Parameter(description = "Sort field") @RequestParam(required = false) DocumentSort sort,
@Parameter(description = "Sort direction: ASC or DESC") @RequestParam(required = false, defaultValue = "DESC") String dir, @Parameter(description = "Sort direction: ASC or DESC") @RequestParam(required = false, defaultValue = "DESC") String dir,
@Parameter(description = "Tag operator: AND (default) or OR") @RequestParam(required = false) String tagOp) { @Parameter(description = "Tag operator: AND (default) or OR") @RequestParam(required = false) String tagOp,
// @Max on page guards against overflow when pageable.getOffset() is computed
// as page * size — Integer.MAX_VALUE * 50 would wrap to a negative long, which
// Hibernate cheerfully turns into an invalid SQL OFFSET.
@Parameter(description = "Page number (0-indexed)") @RequestParam(defaultValue = "0") @Min(0) @Max(100_000) int page,
@Parameter(description = "Page size (max 100)") @RequestParam(defaultValue = "50") @Min(1) @Max(100) int size) {
if (!"ASC".equalsIgnoreCase(dir) && !"DESC".equalsIgnoreCase(dir)) { if (!"ASC".equalsIgnoreCase(dir) && !"DESC".equalsIgnoreCase(dir)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "dir must be ASC or DESC"); throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "dir must be ASC or DESC");
} }
// tagOp is a raw String at the HTTP boundary; any value other than "OR" (case-insensitive) // tagOp is a raw String at the HTTP boundary; any value other than "OR" (case-insensitive)
// defaults to AND, which matches the frontend default and keeps old clients working. // defaults to AND, which matches the frontend default and keeps old clients working.
TagOperator operator = "OR".equalsIgnoreCase(tagOp) ? TagOperator.OR : TagOperator.AND; TagOperator operator = "OR".equalsIgnoreCase(tagOp) ? TagOperator.OR : TagOperator.AND;
return ResponseEntity.ok(documentService.searchDocuments(q, from, to, senderId, receiverId, tags, tagQ, status, sort, dir, operator)); Pageable pageable = PageRequest.of(page, size);
return ResponseEntity.ok(documentService.searchDocuments(q, from, to, senderId, receiverId, tags, tagQ, status, sort, dir, operator, pageable));
} }
// --- TRAINING LABELS --- // --- TRAINING LABELS ---

View File

@@ -82,7 +82,7 @@ public class DashboardService {
.toList(); .toList();
return new DashboardResumeDTO(docId, doc.getTitle(), caption, excerpt, return new DashboardResumeDTO(docId, doc.getTitle(), caption, excerpt,
totalBlocks, pct, null, collaborators); totalBlocks, pct, doc.getThumbnailUrl(), collaborators);
} }
public DashboardPulseDTO getPulse(UUID userId) { public DashboardPulseDTO getPulse(UUID userId) {

View File

@@ -0,0 +1,9 @@
package org.raddatz.familienarchiv.dto;
import java.util.List;
import java.util.UUID;
import io.swagger.v3.oas.annotations.media.Schema;
public record BatchMetadataRequest(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<UUID> ids) {}

View File

@@ -0,0 +1,9 @@
package org.raddatz.familienarchiv.dto;
import java.util.UUID;
import io.swagger.v3.oas.annotations.media.Schema;
public record BulkEditError(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String message) {}

View File

@@ -0,0 +1,9 @@
package org.raddatz.familienarchiv.dto;
import java.util.List;
import io.swagger.v3.oas.annotations.media.Schema;
public record BulkEditResult(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int updated,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<BulkEditError> errors) {}

View File

@@ -0,0 +1,18 @@
package org.raddatz.familienarchiv.dto;
import lombok.Data;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
@Data
public class DocumentBatchMetadataDTO {
private List<String> titles;
private UUID senderId;
private List<UUID> receiverIds;
private LocalDate documentDate;
private String location;
private List<String> tagNames;
private Boolean metadataComplete;
}

View File

@@ -0,0 +1,10 @@
package org.raddatz.familienarchiv.dto;
import java.util.UUID;
import io.swagger.v3.oas.annotations.media.Schema;
public record DocumentBatchSummary(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String title,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String pdfUrl) {}

View File

@@ -0,0 +1,60 @@
package org.raddatz.familienarchiv.dto;
import java.util.List;
import java.util.UUID;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* Request body for {@code PATCH /api/documents/bulk}. Field semantics:
* <ul>
* <li>{@code tagNames} and {@code receiverIds} are <b>additive</b> —
* merged into each document's existing set, never replacing it.</li>
* <li>{@code senderId}, {@code documentLocation}, {@code archiveBox},
* {@code archiveFolder} are <b>replace-on-non-blank</b> — null/blank
* fields are skipped, anything else overwrites.</li>
* </ul>
*
* <p>Kept as a Lombok {@code @Data} POJO (not a record) for symmetry with
* the existing {@code DocumentUpdateDTO} and to keep test setup terse —
* the per-feature DTOs introduced alongside this one ({@link BulkEditError},
* {@link BulkEditResult}, {@link BatchMetadataRequest},
* {@link DocumentBatchSummary}) <i>are</i> records because they have no
* test-side mutation. Tracked in the cycle-1 review for follow-up.
*
* <p>Bean-validation caps below defend against payload-amplification: the
* 1 MiB SvelteKit proxy cap allows ~26k UUIDs through to the backend, and
* Jetty's default body limit is 8 MB. {@code @Size} guards catch malformed
* clients without depending on those outer bounds.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class DocumentBulkEditDTO {
// No @Size cap here on purpose: the controller's BULK_EDIT_MAX_IDS check
// returns the typed BULK_EDIT_TOO_MANY_IDS error code, which the frontend
// maps to a localised "Maximal 500 …" message via Paraglide. A bean-
// validation @Size would short-circuit that with a generic VALIDATION_ERROR.
private List<UUID> documentIds;
@Size(max = 200, message = "tagNames must not exceed 200 entries")
private List<@Size(max = 200, message = "tagName must not exceed 200 chars") String> tagNames;
private UUID senderId;
@Size(max = 200, message = "receiverIds must not exceed 200 entries")
private List<UUID> receiverIds;
@Size(max = 255, message = "documentLocation must not exceed 255 chars")
private String documentLocation;
@Size(max = 255, message = "archiveBox must not exceed 255 chars")
private String archiveBox;
@Size(max = 255, message = "archiveFolder must not exceed 255 chars")
private String archiveFolder;
}

View File

@@ -1,6 +1,7 @@
package org.raddatz.familienarchiv.dto; package org.raddatz.familienarchiv.dto;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import org.springframework.data.domain.Pageable;
import java.util.List; import java.util.List;
@@ -8,9 +9,30 @@ public record DocumentSearchResult(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) @Schema(requiredMode = Schema.RequiredMode.REQUIRED)
List<DocumentSearchItem> items, List<DocumentSearchItem> items,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) @Schema(requiredMode = Schema.RequiredMode.REQUIRED)
long total long totalElements,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
int pageNumber,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
int pageSize,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
int totalPages
) { ) {
/**
* Single-page convenience factory used by empty-result shortcuts and by tests that
* don't care about paging. Treats the whole list as page 0 of itself.
*/
public static DocumentSearchResult of(List<DocumentSearchItem> items) { public static DocumentSearchResult of(List<DocumentSearchItem> items) {
return new DocumentSearchResult(items, items.size()); int size = items.size();
return new DocumentSearchResult(items, size, 0, size, size == 0 ? 0 : 1);
}
/**
* Paged factory used by the service when it has a real Pageable + full match count
* (e.g. from Spring's Page<T> or from an in-memory sort-then-slice).
*/
public static DocumentSearchResult paged(List<DocumentSearchItem> slice, Pageable pageable, long totalElements) {
int pageSize = pageable.getPageSize();
int totalPages = pageSize == 0 ? 0 : (int) ((totalElements + pageSize - 1) / pageSize);
return new DocumentSearchResult(slice, totalElements, pageable.getPageNumber(), pageSize, totalPages);
} }
} }

View File

@@ -109,6 +109,10 @@ public enum ErrorCode {
// --- Generic --- // --- Generic ---
/** Request validation failed (missing or malformed fields). 400 */ /** Request validation failed (missing or malformed fields). 400 */
VALIDATION_ERROR, VALIDATION_ERROR,
/** Batch upload exceeds the maximum allowed file count per request. 400 */
BATCH_TOO_LARGE,
/** Bulk edit request exceeds the per-request document ID cap. 400 */
BULK_EDIT_TOO_MANY_IDS,
/** An unexpected server-side error occurred. 500 */ /** An unexpected server-side error occurred. 500 */
INTERNAL_ERROR, INTERNAL_ERROR,
} }

View File

@@ -6,8 +6,11 @@ import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp; import org.hibernate.annotations.UpdateTimestamp;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.HashSet; import java.util.HashSet;
@@ -50,6 +53,13 @@ public class Document {
@Column(name = "thumbnail_generated_at") @Column(name = "thumbnail_generated_at")
private LocalDateTime thumbnailGeneratedAt; private LocalDateTime thumbnailGeneratedAt;
@Enumerated(EnumType.STRING)
@Column(name = "thumbnail_aspect", length = 16)
private ThumbnailAspect thumbnailAspect;
@Column(name = "page_count")
private Integer pageCount;
// Originaler Dateiname beim Upload (z.B. "Brief_Oma_1940.pdf") // Originaler Dateiname beim Upload (z.B. "Brief_Oma_1940.pdf")
@Column(name = "original_filename", nullable = false) @Column(name = "original_filename", nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) @Schema(requiredMode = Schema.RequiredMode.REQUIRED)
@@ -124,4 +134,19 @@ public class Document {
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Builder.Default @Builder.Default
private Set<TrainingLabel> trainingLabels = new HashSet<>(); private Set<TrainingLabel> trainingLabels = new HashSet<>();
// The `?v={thumbnailGeneratedAt}` cache-buster is load-bearing: the thumbnail
// endpoint sends `Cache-Control: private, max-age=31536000, immutable`
// (DocumentController.getDocumentThumbnail). `immutable` is only safe because
// this URL changes whenever the underlying file does. Dropping the query param
// would let browsers serve a stale thumbnail for a year after the file is
// replaced, and shared caches could leak one user's thumbnail to another
// (CWE-525).
@JsonProperty("thumbnailUrl")
public String getThumbnailUrl() {
if (thumbnailKey == null) return null;
String base = "/api/documents/" + id + "/thumbnail";
if (thumbnailGeneratedAt == null) return base;
return base + "?v=" + URLEncoder.encode(thumbnailGeneratedAt.toString(), StandardCharsets.UTF_8);
}
} }

View File

@@ -0,0 +1,6 @@
package org.raddatz.familienarchiv.model;
public enum ThumbnailAspect {
PORTRAIT,
LANDSCAPE
}

View File

@@ -7,6 +7,9 @@ import org.raddatz.familienarchiv.audit.ActivityActorDTO;
import org.raddatz.familienarchiv.audit.AuditKind; import org.raddatz.familienarchiv.audit.AuditKind;
import org.raddatz.familienarchiv.audit.AuditLogQueryService; import org.raddatz.familienarchiv.audit.AuditLogQueryService;
import org.raddatz.familienarchiv.audit.AuditService; import org.raddatz.familienarchiv.audit.AuditService;
import org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO;
import org.raddatz.familienarchiv.dto.DocumentBatchSummary;
import org.raddatz.familienarchiv.dto.DocumentBulkEditDTO;
import org.raddatz.familienarchiv.dto.DocumentSearchItem; import org.raddatz.familienarchiv.dto.DocumentSearchItem;
import org.raddatz.familienarchiv.dto.DocumentSearchResult; import org.raddatz.familienarchiv.dto.DocumentSearchResult;
import org.raddatz.familienarchiv.dto.DocumentSort; import org.raddatz.familienarchiv.dto.DocumentSort;
@@ -22,7 +25,9 @@ import org.raddatz.familienarchiv.model.TrainingLabel;
import org.raddatz.familienarchiv.model.Person; import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.model.Tag; import org.raddatz.familienarchiv.model.Tag;
import org.raddatz.familienarchiv.repository.DocumentRepository; import org.raddatz.familienarchiv.repository.DocumentRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification; import org.springframework.data.jpa.domain.Specification;
import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.exception.DomainException;
@@ -130,6 +135,52 @@ public class DocumentService {
return new StoreResult(saved, isNew); return new StoreResult(saved, isNew);
} }
public void validateBatch(int fileCount, DocumentBatchMetadataDTO metadata) {
// 50-file hard cap keeps FormData requests at a manageable size and protects against runaway bulk uploads.
if (fileCount > 50) {
throw DomainException.badRequest(ErrorCode.BATCH_TOO_LARGE, "Batch exceeds maximum of 50 files per request");
}
if (metadata != null && metadata.getTitles() != null && metadata.getTitles().size() > fileCount) {
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR, "titles count must not exceed files count");
}
}
@Transactional
public StoreResult storeDocumentWithBatchMetadata(
MultipartFile file, DocumentBatchMetadataDTO metadata, int fileIndex, UUID actorId) throws IOException {
StoreResult base = storeDocument(file, actorId);
Document doc = applyBatchMetadata(base.document(), metadata, fileIndex);
return new StoreResult(documentRepository.save(doc), base.isNew());
}
private Document applyBatchMetadata(Document doc, DocumentBatchMetadataDTO metadata, int fileIndex) {
if (metadata.getTitles() != null && fileIndex < metadata.getTitles().size()) {
doc.setTitle(metadata.getTitles().get(fileIndex));
}
if (metadata.getSenderId() != null) {
doc.setSender(personService.getById(metadata.getSenderId()));
}
if (metadata.getReceiverIds() != null && !metadata.getReceiverIds().isEmpty()) {
doc.setReceivers(new HashSet<>(personService.getAllById(metadata.getReceiverIds())));
}
if (metadata.getDocumentDate() != null) {
doc.setDocumentDate(metadata.getDocumentDate());
}
if (metadata.getLocation() != null) {
doc.setLocation(metadata.getLocation());
}
if (metadata.getMetadataComplete() != null) {
doc.setMetadataComplete(metadata.getMetadataComplete());
}
if (metadata.getTagNames() != null && !metadata.getTagNames().isEmpty()) {
UUID docId = doc.getId();
updateDocumentTags(docId, metadata.getTagNames());
doc = documentRepository.findById(docId)
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Not found after batch metadata: " + docId));
}
return doc;
}
@Transactional @Transactional
public Document createDocument(DocumentUpdateDTO dto, MultipartFile file) throws IOException { public Document createDocument(DocumentUpdateDTO dto, MultipartFile file) throws IOException {
String filename = (file != null && !file.isEmpty()) String filename = (file != null && !file.isEmpty())
@@ -285,20 +336,143 @@ public class DocumentService {
public Document updateDocumentTags(UUID docId, List<String> tagNames) { public Document updateDocumentTags(UUID docId, List<String> tagNames) {
Document doc = documentRepository.findById(docId) Document doc = documentRepository.findById(docId)
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + docId)); .orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + docId));
doc.setTags(resolveTags(tagNames));
return documentRepository.save(doc);
}
Set<Tag> newTags = new HashSet<>(); /**
* Resolves a list of tag-name strings to {@link Tag} entities, trimming
* whitespace and skipping blank entries. Single source of truth for
* "name string → Tag" so the find-or-create policy stays consistent
* across single-doc updates ({@link #updateDocumentTags}), bulk edits
* ({@link #applyBulkEditToDocument}), and the upload-batch path
* ({@code applyBatchMetadata}).
*/
private Set<Tag> resolveTags(List<String> tagNames) {
if (tagNames == null || tagNames.isEmpty()) return new HashSet<>();
Set<Tag> resolved = new HashSet<>();
for (String name : tagNames) { for (String name : tagNames) {
// Clean the string
String cleanName = name.trim(); String cleanName = name.trim();
if (cleanName.isEmpty()) if (cleanName.isEmpty()) continue;
continue; resolved.add(tagService.findOrCreate(cleanName));
}
return resolved;
}
newTags.add(tagService.findOrCreate(cleanName)); /**
* Returns all document IDs matching the given filter parameters, ignoring
* pagination. Used by the bulk-edit "Alle X editieren" fast path so the
* frontend can replace the selection with every match across pages in one
* round-trip.
*/
@Transactional(readOnly = true)
public List<UUID> findIdsForFilter(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver,
List<String> tags, String tagQ, DocumentStatus status, TagOperator tagOperator) {
boolean hasText = StringUtils.hasText(text);
List<UUID> rankedIds = null;
if (hasText) {
rankedIds = documentRepository.findRankedIdsByFts(text);
if (rankedIds.isEmpty()) return List.of();
} }
doc.setTags(newTags); Specification<Document> spec = buildSearchSpec(
return documentRepository.save(doc); hasText, rankedIds, from, to, sender, receiver, tags, tagQ, status, tagOperator);
return documentRepository.findAll(spec).stream().map(Document::getId).toList();
}
/**
* Single source of truth for the search Specification chain. Shared by
* {@link #searchDocuments} (paged + sorted) and {@link #findIdsForFilter}
* (uncapped, ID-only). Caller does its own FTS short-circuit when the
* full-text query returned no rows.
*/
private Specification<Document> buildSearchSpec(boolean hasText, List<UUID> ftsIds,
LocalDate from, LocalDate to,
UUID sender, UUID receiver,
List<String> tags, String tagQ,
DocumentStatus status, TagOperator tagOperator) {
boolean useOrLogic = tagOperator == TagOperator.OR;
List<Set<UUID>> expandedTagSets = tagService.expandTagNamesToDescendantIdSets(tags);
Specification<Document> textSpec = hasText ? hasIds(ftsIds) : (root, query, cb) -> null;
return Specification.where(textSpec)
.and(isBetween(from, to))
.and(hasSender(sender))
.and(hasReceiver(receiver))
.and(hasTags(expandedTagSets, useOrLogic))
.and(hasTagPartial(tagQ))
.and(hasStatus(status));
}
/**
* Returns lightweight summaries (id, title, server PDF URL) for the given
* document IDs. Unknown IDs are silently dropped — the consumer is the
* bulk-edit page's left strip, where missing previews would already be
* obvious; surfacing them as errors here adds no value.
*/
@Transactional(readOnly = true)
public List<DocumentBatchSummary> batchMetadata(List<UUID> ids) {
if (ids == null || ids.isEmpty()) return List.of();
return documentRepository.findAllById(ids).stream()
.map(d -> new DocumentBatchSummary(
d.getId(),
d.getTitle() != null ? d.getTitle() : d.getOriginalFilename(),
"/api/documents/" + d.getId() + "/file"))
.toList();
}
/**
* Applies a bulk-edit DTO to a single document atomically.
* Tags and receivers are additive (merged into existing sets); sender and the
* three location fields are replace-on-non-blank (null/blank means "no change").
* Wrapped in its own transaction so a failure on one document never partially
* mutates another in the controller's batch loop.
*
* Each successful update emits a {@link AuditKind#METADATA_UPDATED} audit
* event tagged {@code source=BULK_EDIT} and writes a row to
* {@code document_versions} so the family archive's "who changed what"
* trail stays complete across both single- and bulk-doc edit paths.
*
* NOTE on N+1: tag and person resolution happens per-document. With 500
* documents × 10 tags this fans out to ~5000 tag-resolve queries per
* request. Acceptable today because the family archive is bounded at
* ~1500 documents total. Tracked as a perf follow-up.
*/
@Transactional
public Document applyBulkEditToDocument(UUID id, DocumentBulkEditDTO dto, UUID actorId) {
Document doc = documentRepository.findById(id)
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id));
if (dto.getTagNames() != null && !dto.getTagNames().isEmpty()) {
Set<Tag> merged = new HashSet<>(doc.getTags());
merged.addAll(resolveTags(dto.getTagNames()));
doc.setTags(merged);
}
if (dto.getSenderId() != null) {
doc.setSender(personService.getById(dto.getSenderId()));
}
if (dto.getReceiverIds() != null && !dto.getReceiverIds().isEmpty()) {
Set<Person> merged = new HashSet<>(doc.getReceivers());
merged.addAll(personService.getAllById(dto.getReceiverIds()));
doc.setReceivers(merged);
}
if (StringUtils.hasText(dto.getDocumentLocation())) {
doc.setDocumentLocation(dto.getDocumentLocation());
}
if (StringUtils.hasText(dto.getArchiveBox())) {
doc.setArchiveBox(dto.getArchiveBox());
}
if (StringUtils.hasText(dto.getArchiveFolder())) {
doc.setArchiveFolder(dto.getArchiveFolder());
}
Document saved = documentRepository.save(doc);
documentVersionService.recordVersion(saved);
auditService.logAfterCommit(AuditKind.METADATA_UPDATED, actorId, saved.getId(),
Map.of("source", "BULK_EDIT"));
return saved;
} }
/** /**
@@ -355,7 +529,7 @@ public class DocumentService {
} }
// 1. Allgemeine Suche (für das Suchfeld im Frontend) // 1. Allgemeine Suche (für das Suchfeld im Frontend)
public DocumentSearchResult searchDocuments(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver, List<String> tags, String tagQ, DocumentStatus status, DocumentSort sort, String dir, TagOperator tagOperator) { public DocumentSearchResult searchDocuments(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver, List<String> tags, String tagQ, DocumentStatus status, DocumentSort sort, String dir, TagOperator tagOperator, Pageable pageable) {
boolean hasText = StringUtils.hasText(text); boolean hasText = StringUtils.hasText(text);
List<UUID> rankedIds = null; List<UUID> rankedIds = null;
@@ -364,27 +538,21 @@ public class DocumentService {
if (rankedIds.isEmpty()) return DocumentSearchResult.of(List.of()); if (rankedIds.isEmpty()) return DocumentSearchResult.of(List.of());
} }
boolean useOrLogic = tagOperator == TagOperator.OR; Specification<Document> spec = buildSearchSpec(
List<Set<UUID>> expandedTagSets = tagService.expandTagNamesToDescendantIdSets(tags); hasText, rankedIds, from, to, sender, receiver, tags, tagQ, status, tagOperator);
Specification<Document> textSpec = hasText ? hasIds(rankedIds) : (root, query, cb) -> null; // SENDER, RECEIVER and RELEVANCE sorts load the full match set and slice in memory.
Specification<Document> spec = Specification.where(textSpec) // JPA's Sort.by("sender.lastName") generates an INNER JOIN that silently drops
.and(isBetween(from, to)) // documents with null sender/receivers; RELEVANCE maps a DB order to an external
.and(hasSender(sender)) // rank list. Cost scales linearly with match count — acceptable while documents
.and(hasReceiver(receiver)) // stays under ~10k rows. Past that, replace with SQL-level LEFT JOIN sort.
.and(hasTags(expandedTagSets, useOrLogic))
.and(hasTagPartial(tagQ))
.and(hasStatus(status));
// SENDER and RECEIVER are sorted in-memory because JPA's Sort.by("sender.lastName")
// generates an INNER JOIN that silently drops documents with null sender/receivers.
if (sort == DocumentSort.RECEIVER) { if (sort == DocumentSort.RECEIVER) {
List<Document> results = documentRepository.findAll(spec); List<Document> sorted = sortByFirstReceiver(documentRepository.findAll(spec), dir);
return buildResult(sortByFirstReceiver(results, dir), text); return buildResultPaged(pageSlice(sorted, pageable), text, pageable, sorted.size());
} }
if (sort == DocumentSort.SENDER) { if (sort == DocumentSort.SENDER) {
List<Document> results = documentRepository.findAll(spec); List<Document> sorted = sortBySender(documentRepository.findAll(spec), dir);
return buildResult(sortBySender(results, dir), text); return buildResultPaged(pageSlice(sorted, pageable), text, pageable, sorted.size());
} }
// RELEVANCE: default when text present and no explicit sort given // RELEVANCE: default when text present and no explicit sort given
@@ -397,15 +565,26 @@ public class DocumentService {
.sorted(Comparator.comparingInt( .sorted(Comparator.comparingInt(
doc -> rankMap.getOrDefault(doc.getId(), Integer.MAX_VALUE))) doc -> rankMap.getOrDefault(doc.getId(), Integer.MAX_VALUE)))
.toList(); .toList();
return buildResult(sorted, text); return buildResultPaged(pageSlice(sorted, pageable), text, pageable, sorted.size());
} }
Sort springSort = resolveSort(sort, dir); // Fast path — push sort + paging into the DB and enrich only the returned slice.
List<Document> results = documentRepository.findAll(spec, springSort); PageRequest pageRequest = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), resolveSort(sort, dir));
return buildResult(results, text); Page<Document> page = documentRepository.findAll(spec, pageRequest);
return buildResultPaged(page.getContent(), text, pageable, page.getTotalElements());
} }
private DocumentSearchResult buildResult(List<Document> documents, String text) { private static <T> List<T> pageSlice(List<T> sorted, Pageable pageable) {
int from = Math.min((int) pageable.getOffset(), sorted.size());
int to = Math.min(from + pageable.getPageSize(), sorted.size());
return sorted.subList(from, to);
}
private DocumentSearchResult buildResultPaged(List<Document> slice, String text, Pageable pageable, long totalElements) {
return DocumentSearchResult.paged(enrichItems(slice, text), pageable, totalElements);
}
private List<DocumentSearchItem> enrichItems(List<Document> documents, String text) {
List<Document> colorResolved = resolveDocumentTagColors(documents); List<Document> colorResolved = resolveDocumentTagColors(documents);
Map<UUID, SearchMatchData> matchData = enrichWithMatchData(colorResolved, text); Map<UUID, SearchMatchData> matchData = enrichWithMatchData(colorResolved, text);
@@ -413,14 +592,12 @@ public class DocumentService {
Map<UUID, Integer> completionByDoc = fetchCompletionPercentages(docIds); Map<UUID, Integer> completionByDoc = fetchCompletionPercentages(docIds);
Map<UUID, List<ActivityActorDTO>> contributorsByDoc = auditLogQueryService.findRecentContributorsPerDocument(docIds); Map<UUID, List<ActivityActorDTO>> contributorsByDoc = auditLogQueryService.findRecentContributorsPerDocument(docIds);
List<DocumentSearchItem> items = colorResolved.stream().map(doc -> new DocumentSearchItem( return colorResolved.stream().map(doc -> new DocumentSearchItem(
doc, doc,
matchData.getOrDefault(doc.getId(), SearchMatchData.empty()), matchData.getOrDefault(doc.getId(), SearchMatchData.empty()),
completionByDoc.getOrDefault(doc.getId(), 0), completionByDoc.getOrDefault(doc.getId(), 0),
contributorsByDoc.getOrDefault(doc.getId(), List.of()) contributorsByDoc.getOrDefault(doc.getId(), List.of())
)).toList(); )).toList();
return DocumentSearchResult.of(items);
} }
private Map<UUID, Integer> fetchCompletionPercentages(List<UUID> docIds) { private Map<UUID, Integer> fetchCompletionPercentages(List<UUID> docIds) {

View File

@@ -7,6 +7,7 @@ import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.rendering.ImageType; import org.apache.pdfbox.rendering.ImageType;
import org.apache.pdfbox.rendering.PDFRenderer; import org.apache.pdfbox.rendering.PDFRenderer;
import org.raddatz.familienarchiv.model.Document; import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.ThumbnailAspect;
import org.raddatz.familienarchiv.repository.DocumentRepository; import org.raddatz.familienarchiv.repository.DocumentRepository;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -45,6 +46,9 @@ public class ThumbnailService {
private static final int THUMBNAIL_WIDTH = 240; private static final int THUMBNAIL_WIDTH = 240;
private static final float JPEG_QUALITY = 0.85f; private static final float JPEG_QUALITY = 0.85f;
private static final int PDF_RENDER_DPI = 100; private static final int PDF_RENDER_DPI = 100;
// Anything below this w/h ratio stays PORTRAIT — near-square A4 scans should
// render in the portrait tile rather than flipping to landscape at 1.01.
private static final float LANDSCAPE_THRESHOLD = 1.1f;
private static final String PDF_CONTENT_TYPE = "application/pdf"; private static final String PDF_CONTENT_TYPE = "application/pdf";
private static final Set<String> IMAGE_CONTENT_TYPES = private static final Set<String> IMAGE_CONTENT_TYPES =
Set.of("image/jpeg", "image/png", "image/tiff"); Set.of("image/jpeg", "image/png", "image/tiff");
@@ -82,27 +86,46 @@ public class ThumbnailService {
return Outcome.SKIPPED; return Outcome.SKIPPED;
} }
BufferedImage source = readSourceImage(doc, contentType); SourcePreview preview = readSourcePreview(doc, contentType);
if (source == null) return Outcome.FAILED; if (preview == null
|| preview.image().getWidth() <= 0 || preview.image().getHeight() <= 0) {
log.warn("Thumbnail source has invalid dimensions for doc={}", doc.getId());
return Outcome.FAILED;
}
byte[] jpeg = encodeThumbnail(source, doc.getId()); byte[] jpeg = encodeThumbnail(preview.image(), doc.getId());
if (jpeg == null) return Outcome.FAILED; if (jpeg == null) return Outcome.FAILED;
String thumbnailKey = thumbnailKeyFor(doc.getId()); String thumbnailKey = thumbnailKeyFor(doc.getId());
if (!uploadToStorage(thumbnailKey, jpeg, doc.getId())) return Outcome.FAILED; if (!uploadToStorage(thumbnailKey, jpeg, doc.getId())) return Outcome.FAILED;
return persistThumbnailMetadata(doc, thumbnailKey); ThumbnailResult result = new ThumbnailResult(
thumbnailKey, aspectOf(preview.image()), preview.pageCount());
return persistThumbnailMetadata(doc, result);
} }
private static ThumbnailAspect aspectOf(BufferedImage source) {
float ratio = (float) source.getWidth() / source.getHeight();
return ratio > LANDSCAPE_THRESHOLD ? ThumbnailAspect.LANDSCAPE : ThumbnailAspect.PORTRAIT;
}
// First-page image + total page count for the source file. Page count is always
// 1 for image uploads; for PDFs it comes straight from PDDocument.
private record SourcePreview(BufferedImage image, int pageCount) {}
// Everything the generate pipeline has already committed to storage and
// now wants stamped onto the Document entity in a single save call.
private record ThumbnailResult(String key, ThumbnailAspect aspect, int pageCount) {}
private static String thumbnailKeyFor(UUID documentId) { private static String thumbnailKeyFor(UUID documentId) {
return THUMBNAIL_KEY_PREFIX + documentId + THUMBNAIL_KEY_SUFFIX; return THUMBNAIL_KEY_PREFIX + documentId + THUMBNAIL_KEY_SUFFIX;
} }
private BufferedImage readSourceImage(Document doc, String contentType) { private SourcePreview readSourcePreview(Document doc, String contentType) {
try { try {
return PDF_CONTENT_TYPE.equals(contentType) return PDF_CONTENT_TYPE.equals(contentType)
? renderPdfFirstPage(doc.getFilePath()) ? renderPdfFirstPage(doc.getFilePath())
: readImage(doc.getFilePath()); : new SourcePreview(readImage(doc.getFilePath()), 1);
} catch (Exception e) { } catch (Exception e) {
log.warn("Thumbnail source read failed for doc={} reason={}", log.warn("Thumbnail source read failed for doc={} reason={}",
doc.getId(), e.getMessage()); doc.getId(), e.getMessage());
@@ -138,10 +161,12 @@ public class ThumbnailService {
} }
} }
private Outcome persistThumbnailMetadata(Document doc, String thumbnailKey) { private Outcome persistThumbnailMetadata(Document doc, ThumbnailResult result) {
try { try {
doc.setThumbnailKey(thumbnailKey); doc.setThumbnailKey(result.key());
doc.setThumbnailGeneratedAt(LocalDateTime.now()); doc.setThumbnailGeneratedAt(LocalDateTime.now());
doc.setThumbnailAspect(result.aspect());
doc.setPageCount(result.pageCount());
documentRepository.save(doc); documentRepository.save(doc);
return Outcome.SUCCESS; return Outcome.SUCCESS;
} catch (Exception e) { } catch (Exception e) {
@@ -151,7 +176,7 @@ public class ThumbnailService {
// overwrite it cleanly. Logging distinctly so an operator tracking // overwrite it cleanly. Logging distinctly so an operator tracking
// backfill totals can spot the database-side issue. // backfill totals can spot the database-side issue.
log.warn("Thumbnail persist failed for doc={} (orphaned in storage as {}): {}", log.warn("Thumbnail persist failed for doc={} (orphaned in storage as {}): {}",
doc.getId(), thumbnailKey, e.getMessage()); doc.getId(), result.key(), e.getMessage());
return Outcome.FAILED; return Outcome.FAILED;
} }
} }
@@ -160,11 +185,12 @@ public class ThumbnailService {
return PDF_CONTENT_TYPE.equals(contentType) || IMAGE_CONTENT_TYPES.contains(contentType); return PDF_CONTENT_TYPE.equals(contentType) || IMAGE_CONTENT_TYPES.contains(contentType);
} }
private BufferedImage renderPdfFirstPage(String s3Key) throws IOException { private SourcePreview renderPdfFirstPage(String s3Key) throws IOException {
try (InputStream in = fileService.downloadFileStream(s3Key); try (InputStream in = fileService.downloadFileStream(s3Key);
PDDocument pdf = Loader.loadPDF(new RandomAccessReadBuffer(in))) { PDDocument pdf = Loader.loadPDF(new RandomAccessReadBuffer(in))) {
PDFRenderer renderer = new PDFRenderer(pdf); PDFRenderer renderer = new PDFRenderer(pdf);
return renderer.renderImageWithDPI(0, PDF_RENDER_DPI, ImageType.RGB); BufferedImage image = renderer.renderImageWithDPI(0, PDF_RENDER_DPI, ImageType.RGB);
return new SourcePreview(image, pdf.getNumberOfPages());
} }
} }

View File

@@ -23,7 +23,8 @@ spring:
servlet: servlet:
multipart: multipart:
max-file-size: 50MB max-file-size: 50MB
max-request-size: 50MB max-request-size: 500MB # supports 10-file chunk at max per-file size; see #317
file-size-threshold: 2KB
mail: mail:
host: ${MAIL_HOST:} host: ${MAIL_HOST:}

View File

@@ -0,0 +1,8 @@
-- Adds two nullable metadata columns populated by ThumbnailService when it
-- generates the JPEG preview: thumbnail_aspect (PORTRAIT | LANDSCAPE, from the
-- source image w/h ratio with threshold 1.1) and page_count (from PDDocument
-- for PDFs, 1 for image uploads). Both are null until the existing admin
-- backfill endpoint (/api/admin/generate-thumbnails) reruns the service.
ALTER TABLE documents
ADD COLUMN thumbnail_aspect VARCHAR(16),
ADD COLUMN page_count INTEGER;

View File

@@ -1,15 +1,17 @@
package org.raddatz.familienarchiv.controller; package org.raddatz.familienarchiv.controller;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO;
import org.raddatz.familienarchiv.dto.DocumentSearchResult; import org.raddatz.familienarchiv.dto.DocumentSearchResult;
import org.raddatz.familienarchiv.dto.DocumentVersionSummary; import org.raddatz.familienarchiv.dto.DocumentVersionSummary;
import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode; import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.model.Document; import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentStatus; import org.raddatz.familienarchiv.model.DocumentStatus;
import org.raddatz.familienarchiv.model.DocumentVersion; import org.raddatz.familienarchiv.model.DocumentVersion;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.security.PermissionAspect; import org.raddatz.familienarchiv.security.PermissionAspect;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.service.CustomUserDetailsService; import org.raddatz.familienarchiv.service.CustomUserDetailsService;
import org.raddatz.familienarchiv.service.DocumentService; import org.raddatz.familienarchiv.service.DocumentService;
import org.raddatz.familienarchiv.service.DocumentVersionService; import org.raddatz.familienarchiv.service.DocumentVersionService;
@@ -69,7 +71,7 @@ class DocumentControllerTest {
@Test @Test
@WithMockUser @WithMockUser
void search_returns200_whenAuthenticated() throws Exception { void search_returns200_whenAuthenticated() throws Exception {
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any())) when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
.thenReturn(DocumentSearchResult.of(List.of())); .thenReturn(DocumentSearchResult.of(List.of()));
mockMvc.perform(get("/api/documents/search")) mockMvc.perform(get("/api/documents/search"))
@@ -79,13 +81,13 @@ class DocumentControllerTest {
@Test @Test
@WithMockUser @WithMockUser
void search_withStatusParam_passesItToService() throws Exception { void search_withStatusParam_passesItToService() throws Exception {
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), eq(DocumentStatus.REVIEWED), any(), any(), any())) when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), eq(DocumentStatus.REVIEWED), any(), any(), any(), any()))
.thenReturn(DocumentSearchResult.of(List.of())); .thenReturn(DocumentSearchResult.of(List.of()));
mockMvc.perform(get("/api/documents/search").param("status", "REVIEWED")) mockMvc.perform(get("/api/documents/search").param("status", "REVIEWED"))
.andExpect(status().isOk()); .andExpect(status().isOk());
verify(documentService).searchDocuments(any(), any(), any(), any(), any(), any(), any(), eq(DocumentStatus.REVIEWED), any(), any(), any()); verify(documentService).searchDocuments(any(), any(), any(), any(), any(), any(), any(), eq(DocumentStatus.REVIEWED), any(), any(), any(), any());
} }
@Test @Test
@@ -112,12 +114,12 @@ class DocumentControllerTest {
@Test @Test
@WithMockUser @WithMockUser
void search_responseContainsTotalCount() throws Exception { void search_responseContainsTotalCount() throws Exception {
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any())) when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
.thenReturn(DocumentSearchResult.of(List.of())); .thenReturn(DocumentSearchResult.of(List.of()));
mockMvc.perform(get("/api/documents/search")) mockMvc.perform(get("/api/documents/search"))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.total").value(0)) .andExpect(jsonPath("$.totalElements").value(0))
.andExpect(jsonPath("$.items").isArray()); .andExpect(jsonPath("$.items").isArray());
} }
@@ -133,7 +135,7 @@ class DocumentControllerTest {
.build(); .build();
var matchData = new SearchMatchData( var matchData = new SearchMatchData(
"Er schrieb einen langen Brief", List.of(), false, List.of(), List.of(), List.of(), null, List.of()); "Er schrieb einen langen Brief", List.of(), false, List.of(), List.of(), List.of(), null, List.of());
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any())) when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
.thenReturn(DocumentSearchResult.of(List.of(new DocumentSearchItem(doc, matchData, 0, List.of())))); .thenReturn(DocumentSearchResult.of(List.of(new DocumentSearchItem(doc, matchData, 0, List.of()))));
mockMvc.perform(get("/api/documents/search").param("q", "Brief")) mockMvc.perform(get("/api/documents/search").param("q", "Brief"))
@@ -143,6 +145,70 @@ class DocumentControllerTest {
.value("Er schrieb einen langen Brief")); .value("Er schrieb einen langen Brief"));
} }
// ─── /api/documents/search pagination ─────────────────────────────────────
@Test
@WithMockUser
void search_responseExposesPagingFields() throws Exception {
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
.thenReturn(DocumentSearchResult.of(List.of()));
mockMvc.perform(get("/api/documents/search"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.pageNumber").exists())
.andExpect(jsonPath("$.pageSize").exists())
.andExpect(jsonPath("$.totalPages").exists())
.andExpect(jsonPath("$.totalElements").exists());
}
@Test
@WithMockUser
void search_returns400_whenSizeExceedsMax() throws Exception {
// Locks @Validated on the controller — removing it silently reopens the
// DoS window where a client could request all 1500 docs + enrichment.
mockMvc.perform(get("/api/documents/search").param("size", "101"))
.andExpect(status().isBadRequest());
}
@Test
@WithMockUser
void search_returns400_whenSizeBelowMin() throws Exception {
mockMvc.perform(get("/api/documents/search").param("size", "0"))
.andExpect(status().isBadRequest());
}
@Test
@WithMockUser
void search_returns400_whenPageNegative() throws Exception {
mockMvc.perform(get("/api/documents/search").param("page", "-1"))
.andExpect(status().isBadRequest());
}
@Test
@WithMockUser
void search_returns400_whenPageAboveMax() throws Exception {
// Guards against page * size overflow into negative SQL OFFSET
mockMvc.perform(get("/api/documents/search").param("page", "200000"))
.andExpect(status().isBadRequest());
}
@Test
@WithMockUser
void search_passesPageRequestToService() throws Exception {
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
.thenReturn(DocumentSearchResult.of(List.of()));
mockMvc.perform(get("/api/documents/search").param("page", "2").param("size", "25"))
.andExpect(status().isOk());
org.mockito.ArgumentCaptor<org.springframework.data.domain.Pageable> captor =
org.mockito.ArgumentCaptor.forClass(org.springframework.data.domain.Pageable.class);
verify(documentService).searchDocuments(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), captor.capture());
org.springframework.data.domain.Pageable pageable = captor.getValue();
org.assertj.core.api.Assertions.assertThat(pageable.getPageNumber()).isEqualTo(2);
org.assertj.core.api.Assertions.assertThat(pageable.getPageSize()).isEqualTo(25);
}
// ─── POST /api/documents ───────────────────────────────────────────────── // ─── POST /api/documents ─────────────────────────────────────────────────
@Test @Test
@@ -702,4 +768,476 @@ class DocumentControllerTest {
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.editorName").value("Otto")); .andExpect(jsonPath("$.editorName").value("Otto"));
} }
// ─── POST /api/documents/quick-upload — metadata part ────────────────────
@Test
@WithMockUser(authorities = "WRITE_ALL")
void quickUpload_withMetadata_appliesSharedFieldsToAllCreatedDocuments() throws Exception {
UUID senderId = UUID.randomUUID();
Person sender = Person.builder().id(senderId).lastName("Müller").build();
Document doc1 = Document.builder().id(UUID.randomUUID()).title("Brief 1").originalFilename("a.pdf").sender(sender).build();
Document doc2 = Document.builder().id(UUID.randomUUID()).title("Brief 2").originalFilename("b.pdf").sender(sender).build();
Document doc3 = Document.builder().id(UUID.randomUUID()).title("Brief 3").originalFilename("c.pdf").sender(sender).build();
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(0), any()))
.thenReturn(new DocumentService.StoreResult(doc1, true));
when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(1), any()))
.thenReturn(new DocumentService.StoreResult(doc2, true));
when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(2), any()))
.thenReturn(new DocumentService.StoreResult(doc3, true));
org.springframework.mock.web.MockMultipartFile f1 =
new org.springframework.mock.web.MockMultipartFile("files", "a.pdf", "application/pdf", new byte[]{1});
org.springframework.mock.web.MockMultipartFile f2 =
new org.springframework.mock.web.MockMultipartFile("files", "b.pdf", "application/pdf", new byte[]{1});
org.springframework.mock.web.MockMultipartFile f3 =
new org.springframework.mock.web.MockMultipartFile("files", "c.pdf", "application/pdf", new byte[]{1});
org.springframework.mock.web.MockMultipartFile metadata =
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
("{\"senderId\":\"" + senderId + "\"}").getBytes());
mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(f3).file(metadata))
.andExpect(status().isOk())
.andExpect(jsonPath("$.created.length()").value(3))
.andExpect(jsonPath("$.created[0].sender.id").value(senderId.toString()))
.andExpect(jsonPath("$.created[1].sender.id").value(senderId.toString()))
.andExpect(jsonPath("$.created[2].sender.id").value(senderId.toString()))
.andExpect(jsonPath("$.updated").isEmpty())
.andExpect(jsonPath("$.errors").isEmpty());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void quickUpload_withMetadata_appliesSharedFieldsToUpdatedDocuments() throws Exception {
UUID senderId = UUID.randomUUID();
Person sender = Person.builder().id(senderId).lastName("Müller").build();
Document existing = Document.builder().id(UUID.randomUUID()).title("Alt").originalFilename("alt.pdf").sender(sender).build();
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(0), any()))
.thenReturn(new DocumentService.StoreResult(existing, false));
org.springframework.mock.web.MockMultipartFile file =
new org.springframework.mock.web.MockMultipartFile("files", "alt.pdf", "application/pdf", new byte[]{1});
org.springframework.mock.web.MockMultipartFile metadata =
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
("{\"senderId\":\"" + senderId + "\"}").getBytes());
mockMvc.perform(multipart("/api/documents/quick-upload").file(file).file(metadata))
.andExpect(status().isOk())
.andExpect(jsonPath("$.created").isEmpty())
.andExpect(jsonPath("$.updated[0].sender.id").value(senderId.toString()))
.andExpect(jsonPath("$.errors").isEmpty());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void quickUpload_withMetadata_mapsTitlesByIndex() throws Exception {
Document docA = Document.builder().id(UUID.randomUUID()).title("Alpha").originalFilename("a.pdf").build();
Document docB = Document.builder().id(UUID.randomUUID()).title("Beta").originalFilename("b.pdf").build();
Document docC = Document.builder().id(UUID.randomUUID()).title("Gamma").originalFilename("c.pdf").build();
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(0), any()))
.thenReturn(new DocumentService.StoreResult(docA, true));
when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(1), any()))
.thenReturn(new DocumentService.StoreResult(docB, true));
when(documentService.storeDocumentWithBatchMetadata(any(), any(), eq(2), any()))
.thenReturn(new DocumentService.StoreResult(docC, true));
org.springframework.mock.web.MockMultipartFile f1 =
new org.springframework.mock.web.MockMultipartFile("files", "a.pdf", "application/pdf", new byte[]{1});
org.springframework.mock.web.MockMultipartFile f2 =
new org.springframework.mock.web.MockMultipartFile("files", "b.pdf", "application/pdf", new byte[]{1});
org.springframework.mock.web.MockMultipartFile f3 =
new org.springframework.mock.web.MockMultipartFile("files", "c.pdf", "application/pdf", new byte[]{1});
org.springframework.mock.web.MockMultipartFile metadata =
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
"{\"titles\":[\"Alpha\",\"Beta\",\"Gamma\"]}".getBytes());
mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(f3).file(metadata))
.andExpect(status().isOk())
.andExpect(jsonPath("$.created[0].title").value("Alpha"))
.andExpect(jsonPath("$.created[1].title").value("Beta"))
.andExpect(jsonPath("$.created[2].title").value("Gamma"));
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void quickUpload_withMetadata_rejects400_whenTitlesSizeExceedsFilesSize() throws Exception {
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
org.mockito.Mockito.doThrow(
org.raddatz.familienarchiv.exception.DomainException.badRequest(
org.raddatz.familienarchiv.exception.ErrorCode.VALIDATION_ERROR, "titles count must not exceed files count"))
.when(documentService).validateBatch(eq(2), any());
org.springframework.mock.web.MockMultipartFile f1 =
new org.springframework.mock.web.MockMultipartFile("files", "a.pdf", "application/pdf", new byte[]{1});
org.springframework.mock.web.MockMultipartFile f2 =
new org.springframework.mock.web.MockMultipartFile("files", "b.pdf", "application/pdf", new byte[]{1});
org.springframework.mock.web.MockMultipartFile metadata =
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
"{\"titles\":[\"A\",\"B\",\"C\"]}".getBytes());
mockMvc.perform(multipart("/api/documents/quick-upload").file(f1).file(f2).file(metadata))
.andExpect(status().isBadRequest());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void quickUpload_withMetadata_tagNamesJsonArray_parsedCorrectly() throws Exception {
Document doc = Document.builder().id(UUID.randomUUID()).title("brief").originalFilename("brief.pdf").build();
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
org.mockito.ArgumentCaptor<DocumentBatchMetadataDTO> captor =
org.mockito.ArgumentCaptor.forClass(DocumentBatchMetadataDTO.class);
when(documentService.storeDocumentWithBatchMetadata(any(), captor.capture(), anyInt(), any()))
.thenReturn(new DocumentService.StoreResult(doc, true));
org.springframework.mock.web.MockMultipartFile file =
new org.springframework.mock.web.MockMultipartFile("files", "brief.pdf", "application/pdf", new byte[]{1});
org.springframework.mock.web.MockMultipartFile metadata =
new org.springframework.mock.web.MockMultipartFile("metadata", "metadata", "application/json",
"{\"tagNames\":[\"Briefwechsel\",\"Krieg\"]}".getBytes());
mockMvc.perform(multipart("/api/documents/quick-upload").file(file).file(metadata))
.andExpect(status().isOk());
org.assertj.core.api.Assertions.assertThat(captor.getValue().getTagNames())
.containsExactly("Briefwechsel", "Krieg");
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void quickUpload_returns400_whenBatchExceedsCap() throws Exception {
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
org.mockito.Mockito.doThrow(
org.raddatz.familienarchiv.exception.DomainException.badRequest(
org.raddatz.familienarchiv.exception.ErrorCode.BATCH_TOO_LARGE, "Batch exceeds maximum of 50 files per request"))
.when(documentService).validateBatch(eq(51), any());
var builder = multipart("/api/documents/quick-upload");
for (int i = 0; i < 51; i++) {
builder.file(new org.springframework.mock.web.MockMultipartFile(
"files", "f" + i + ".pdf", "application/pdf", new byte[]{1}));
}
mockMvc.perform(builder)
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("BATCH_TOO_LARGE"));
}
// ─── PATCH /api/documents/bulk ───────────────────────────────────────────
private static String bulkBody(String... uuids) {
StringBuilder sb = new StringBuilder("{\"documentIds\":[");
for (int i = 0; i < uuids.length; i++) {
if (i > 0) sb.append(",");
sb.append("\"").append(uuids[i]).append("\"");
}
sb.append("]}");
return sb.toString();
}
@Test
void patchBulk_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(patch("/api/documents/bulk")
.contentType(MediaType.APPLICATION_JSON)
.content(bulkBody(UUID.randomUUID().toString())))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void patchBulk_returns403_forReadAllUser() throws Exception {
mockMvc.perform(patch("/api/documents/bulk")
.contentType(MediaType.APPLICATION_JSON)
.content(bulkBody(UUID.randomUUID().toString())))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void patchBulk_returns400_whenDocumentIdsIsEmpty() throws Exception {
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
mockMvc.perform(patch("/api/documents/bulk")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"documentIds\":[]}"))
.andExpect(status().isBadRequest());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void patchBulk_returns400_whenDocumentIdsIsMissing() throws Exception {
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
mockMvc.perform(patch("/api/documents/bulk")
.contentType(MediaType.APPLICATION_JSON)
.content("{}"))
.andExpect(status().isBadRequest());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void patchBulk_returns400_whenDocumentIdsExceedsCap() throws Exception {
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
String[] ids = new String[501];
for (int i = 0; i < 501; i++) ids[i] = UUID.randomUUID().toString();
mockMvc.perform(patch("/api/documents/bulk")
.contentType(MediaType.APPLICATION_JSON)
.content(bulkBody(ids)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("BULK_EDIT_TOO_MANY_IDS"));
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void patchBulk_returns400_whenArchiveBoxExceeds255Chars() throws Exception {
// Tobias C2 — DocumentBulkEditDTO.archiveBox carries @Size(max=255).
// Without @Valid on @RequestBody this would silently land an
// arbitrarily long string; the test pins both the annotation and
// the controller-level @Valid wiring.
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
UUID id = UUID.randomUUID();
String tooLong = "x".repeat(256);
String body = "{\"documentIds\":[\"" + id + "\"],\"archiveBox\":\"" + tooLong + "\"}";
mockMvc.perform(patch("/api/documents/bulk")
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isBadRequest());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void patchBulk_acceptsExactly500Ids_atTheCap() throws Exception {
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
when(documentService.applyBulkEditToDocument(any(), any(), any()))
.thenAnswer(inv -> Document.builder().id(inv.getArgument(0)).build());
String[] ids = new String[500];
for (int i = 0; i < 500; i++) ids[i] = UUID.randomUUID().toString();
mockMvc.perform(patch("/api/documents/bulk")
.contentType(MediaType.APPLICATION_JSON)
.content(bulkBody(ids)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.updated").value(500));
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void patchBulk_dedupesDuplicateDocumentIds_doesNotInflateUpdatedCount() throws Exception {
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
UUID id = UUID.randomUUID();
when(documentService.applyBulkEditToDocument(eq(id), any(), any()))
.thenAnswer(inv -> Document.builder().id(id).build());
// Same id sent three times — controller should dedupe and call the
// service exactly once, returning updated=1, not 3.
mockMvc.perform(patch("/api/documents/bulk")
.contentType(MediaType.APPLICATION_JSON)
.content(bulkBody(id.toString(), id.toString(), id.toString())))
.andExpect(status().isOk())
.andExpect(jsonPath("$.updated").value(1));
verify(documentService, org.mockito.Mockito.times(1))
.applyBulkEditToDocument(eq(id), any(), any());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void patchBulk_returns200_andCallsServiceForEachId() throws Exception {
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
UUID id1 = UUID.randomUUID();
UUID id2 = UUID.randomUUID();
when(documentService.applyBulkEditToDocument(any(), any(), any()))
.thenAnswer(inv -> Document.builder().id(inv.getArgument(0)).build());
mockMvc.perform(patch("/api/documents/bulk")
.contentType(MediaType.APPLICATION_JSON)
.content(bulkBody(id1.toString(), id2.toString())))
.andExpect(status().isOk())
.andExpect(jsonPath("$.updated").value(2))
.andExpect(jsonPath("$.errors").isEmpty());
verify(documentService).applyBulkEditToDocument(eq(id1), any(), any());
verify(documentService).applyBulkEditToDocument(eq(id2), any(), any());
}
// ─── GET /api/documents/ids ──────────────────────────────────────────────
@Test
void getDocumentIds_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(get("/api/documents/ids"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void getDocumentIds_returns403_forUserWithoutWriteAll() throws Exception {
// /ids is gated WRITE_ALL because it powers the bulk-edit "Alle X
// editieren" fast path; no other consumer needs it.
mockMvc.perform(get("/api/documents/ids"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void getDocumentIds_returns200_andDelegatesToService() throws Exception {
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
UUID id = UUID.randomUUID();
when(documentService.findIdsForFilter(any(), any(), any(), any(), any(), any(), any(), any(), any()))
.thenReturn(List.of(id));
mockMvc.perform(get("/api/documents/ids"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0]").value(id.toString()));
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void getDocumentIds_passesSenderIdParamToService() throws Exception {
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
UUID senderId = UUID.randomUUID();
when(documentService.findIdsForFilter(any(), any(), any(), eq(senderId), any(), any(), any(), any(), any()))
.thenReturn(List.of());
mockMvc.perform(get("/api/documents/ids").param("senderId", senderId.toString()))
.andExpect(status().isOk());
verify(documentService).findIdsForFilter(any(), any(), any(), eq(senderId), any(), any(), any(), any(), any());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void getDocumentIds_returns400_whenResultExceedsFilterCap() throws Exception {
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
// Service returns 5001 IDs — one over BULK_EDIT_FILTER_MAX_IDS (5000).
java.util.List<UUID> tooMany = new java.util.ArrayList<>(5001);
for (int i = 0; i < 5001; i++) tooMany.add(UUID.randomUUID());
when(documentService.findIdsForFilter(any(), any(), any(), any(), any(), any(), any(), any(), any()))
.thenReturn(tooMany);
mockMvc.perform(get("/api/documents/ids"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("BULK_EDIT_TOO_MANY_IDS"));
}
// ─── POST /api/documents/batch-metadata ──────────────────────────────────
@Test
void batchMetadata_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"ids\":[\"" + UUID.randomUUID() + "\"]}"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void batchMetadata_returns403_forUserWithoutReadAll() throws Exception {
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"ids\":[\"" + UUID.randomUUID() + "\"]}"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void batchMetadata_returns400_whenIdsEmpty() throws Exception {
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"ids\":[]}"))
.andExpect(status().isBadRequest());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void batchMetadata_returns400_whenIdsExceedsCap() throws Exception {
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
StringBuilder sb = new StringBuilder("{\"ids\":[");
for (int i = 0; i < 501; i++) {
if (i > 0) sb.append(",");
sb.append("\"").append(UUID.randomUUID()).append("\"");
}
sb.append("]}");
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
.contentType(MediaType.APPLICATION_JSON)
.content(sb.toString()))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("BULK_EDIT_TOO_MANY_IDS"));
}
@Test
@WithMockUser(authorities = "READ_ALL")
void batchMetadata_returnsSummaries_forExistingIds() throws Exception {
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
UUID id = UUID.randomUUID();
when(documentService.batchMetadata(any())).thenReturn(List.of(
new org.raddatz.familienarchiv.dto.DocumentBatchSummary(id, "Brief", "/api/documents/" + id + "/file")));
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/documents/batch-metadata")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"ids\":[\"" + id + "\"]}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].id").value(id.toString()))
.andExpect(jsonPath("$[0].title").value("Brief"))
.andExpect(jsonPath("$[0].pdfUrl").value("/api/documents/" + id + "/file"));
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void patchBulk_stripsCarriageReturnsAndNewlinesFromErrorMessages() throws Exception {
// Nora C4 — DocumentController.sanitizeForLog defends against
// CWE-117 (log injection) by replacing CR/LF in any free-form string
// it interpolates. Same helper now sanitises BulkEditError.message
// before it round-trips to the frontend.
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
UUID badId = UUID.randomUUID();
when(documentService.applyBulkEditToDocument(eq(badId), any(), any()))
.thenThrow(org.raddatz.familienarchiv.exception.DomainException.notFound(
org.raddatz.familienarchiv.exception.ErrorCode.DOCUMENT_NOT_FOUND,
"evil\r\nFAKE LOG ENTRY: admin logged in"));
mockMvc.perform(patch("/api/documents/bulk")
.contentType(MediaType.APPLICATION_JSON)
.content(bulkBody(badId.toString())))
.andExpect(status().isOk())
.andExpect(jsonPath("$.errors[0].message",
org.hamcrest.Matchers.not(org.hamcrest.Matchers.containsString("\n"))))
.andExpect(jsonPath("$.errors[0].message",
org.hamcrest.Matchers.not(org.hamcrest.Matchers.containsString("\r"))))
.andExpect(jsonPath("$.errors[0].message",
org.hamcrest.Matchers.containsString("evil_")));
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void patchBulk_returnsPartialFailureShape_whenServiceThrowsForOneDocument() throws Exception {
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
UUID okId = UUID.randomUUID();
UUID badId = UUID.randomUUID();
when(documentService.applyBulkEditToDocument(eq(okId), any(), any()))
.thenAnswer(inv -> Document.builder().id(okId).build());
when(documentService.applyBulkEditToDocument(eq(badId), any(), any()))
.thenThrow(org.raddatz.familienarchiv.exception.DomainException.notFound(
org.raddatz.familienarchiv.exception.ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + badId));
mockMvc.perform(patch("/api/documents/bulk")
.contentType(MediaType.APPLICATION_JSON)
.content(bulkBody(okId.toString(), badId.toString())))
.andExpect(status().isOk())
.andExpect(jsonPath("$.updated").value(1))
.andExpect(jsonPath("$.errors[0].id").value(badId.toString()))
.andExpect(jsonPath("$.errors[0].message").value(
org.hamcrest.Matchers.containsString("not found")));
}
} }

View File

@@ -18,6 +18,7 @@ import org.raddatz.familienarchiv.service.TranscriptionService;
import org.raddatz.familienarchiv.service.UserService; import org.raddatz.familienarchiv.service.UserService;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDateTime;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
@@ -45,6 +46,31 @@ class DashboardServiceTest {
@InjectMocks DashboardService dashboardService; @InjectMocks DashboardService dashboardService;
// ─── getResume wires thumbnailUrl from Document ───────────────────────────
@Test
void getResume_populatesThumbnailUrl_fromDocument() {
UUID userId = UUID.randomUUID();
UUID docId = UUID.fromString("12345678-aaaa-bbbb-cccc-1234567890ab");
Document doc = Document.builder()
.id(docId).title("Brief").originalFilename("brief.pdf")
.thumbnailKey("thumbnails/" + docId + ".jpg")
.thumbnailGeneratedAt(LocalDateTime.of(2026, 4, 23, 9, 0, 0))
.receivers(new HashSet<>())
.build();
when(auditLogQueryService.findMostRecentDocumentForUser(userId)).thenReturn(Optional.of(docId));
when(documentService.getDocumentById(docId)).thenReturn(doc);
when(transcriptionService.listBlocks(docId)).thenReturn(List.of());
DashboardResumeDTO result = dashboardService.getResume(userId);
assertThat(result).isNotNull();
assertThat(result.thumbnailUrl()).isEqualTo(doc.getThumbnailUrl());
assertThat(result.thumbnailUrl()).startsWith("/api/documents/" + docId + "/thumbnail?v=");
}
// ─── toActorDTO (via getResume collaborators) ───────────────────────────── // ─── toActorDTO (via getResume collaborators) ─────────────────────────────
@Test @Test

View File

@@ -5,6 +5,7 @@ import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.audit.ActivityActorDTO; import org.raddatz.familienarchiv.audit.ActivityActorDTO;
import org.raddatz.familienarchiv.model.Document; import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentStatus; import org.raddatz.familienarchiv.model.DocumentStatus;
import org.springframework.data.domain.PageRequest;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
@@ -24,10 +25,43 @@ class DocumentSearchResultTest {
} }
@Test @Test
void of_total_equals_list_size() { void of_totalElements_equals_list_size_for_unpaged_shortcut() {
DocumentSearchResult result = DocumentSearchResult.of(List.of(item(UUID.randomUUID()), item(UUID.randomUUID()))); DocumentSearchResult result = DocumentSearchResult.of(
List.of(item(UUID.randomUUID()), item(UUID.randomUUID())));
assertThat(result.total()).isEqualTo(2L); assertThat(result.totalElements()).isEqualTo(2L);
assertThat(result.pageNumber()).isZero();
assertThat(result.pageSize()).isEqualTo(2);
assertThat(result.totalPages()).isEqualTo(1);
}
@Test
void of_empty_shortcut_has_zero_totalPages() {
DocumentSearchResult result = DocumentSearchResult.of(List.of());
assertThat(result.totalElements()).isZero();
assertThat(result.totalPages()).isZero();
}
@Test
void paged_factory_populates_paging_fields_from_pageable_and_total() {
List<DocumentSearchItem> slice = List.of(item(UUID.randomUUID()), item(UUID.randomUUID()));
DocumentSearchResult result = DocumentSearchResult.paged(slice, PageRequest.of(1, 50), 120L);
assertThat(result.items()).hasSize(2);
assertThat(result.totalElements()).isEqualTo(120L);
assertThat(result.pageNumber()).isEqualTo(1);
assertThat(result.pageSize()).isEqualTo(50);
assertThat(result.totalPages()).isEqualTo(3); // ceil(120 / 50)
}
@Test
void paged_factory_totalPages_rounds_up_on_remainder() {
DocumentSearchResult result =
DocumentSearchResult.paged(List.of(), PageRequest.of(0, 7), 30L);
assertThat(result.totalPages()).isEqualTo(5); // ceil(30 / 7)
} }
@Test @Test
@@ -53,9 +87,18 @@ class DocumentSearchResultTest {
} }
@Test @Test
void total_component_is_annotated_as_required_in_openapi_schema() throws NoSuchFieldException { void totalElements_component_is_annotated_as_required_in_openapi_schema() throws NoSuchFieldException {
Schema schema = DocumentSearchResult.class.getDeclaredField("total").getAnnotation(Schema.class); Schema schema = DocumentSearchResult.class.getDeclaredField("totalElements").getAnnotation(Schema.class);
assertThat(schema).isNotNull(); assertThat(schema).isNotNull();
assertThat(schema.requiredMode()).isEqualTo(Schema.RequiredMode.REQUIRED); assertThat(schema.requiredMode()).isEqualTo(Schema.RequiredMode.REQUIRED);
} }
@Test
void paging_components_are_annotated_as_required_in_openapi_schema() throws NoSuchFieldException {
for (String name : List.of("pageNumber", "pageSize", "totalPages")) {
Schema schema = DocumentSearchResult.class.getDeclaredField(name).getAnnotation(Schema.class);
assertThat(schema).as(name + " must have @Schema").isNotNull();
assertThat(schema.requiredMode()).isEqualTo(Schema.RequiredMode.REQUIRED);
}
}
} }

View File

@@ -0,0 +1,85 @@
package org.raddatz.familienarchiv.model;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
class DocumentTest {
@Test
void getThumbnailUrl_returnsNull_whenThumbnailKeyNull() {
Document doc = Document.builder()
.id(UUID.randomUUID())
.title("Brief")
.originalFilename("brief.pdf")
.status(DocumentStatus.UPLOADED)
.thumbnailKey(null)
.build();
assertThat(doc.getThumbnailUrl()).isNull();
}
@Test
void getThumbnailUrl_omitsCacheBuster_whenThumbnailKeyPresentButGeneratedAtNull() {
UUID id = UUID.fromString("11111111-2222-3333-4444-555555555555");
Document doc = Document.builder()
.id(id)
.title("Brief")
.originalFilename("brief.pdf")
.status(DocumentStatus.UPLOADED)
.thumbnailKey("thumbnails/" + id + ".jpg")
.thumbnailGeneratedAt(null)
.build();
assertThat(doc.getThumbnailUrl())
.isEqualTo("/api/documents/" + id + "/thumbnail");
}
@Test
void getThumbnailUrl_includesCacheBuster_whenBothKeyAndGeneratedAtPresent() {
UUID id = UUID.fromString("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
LocalDateTime generatedAt = LocalDateTime.of(2026, 4, 23, 14, 30, 45);
Document doc = Document.builder()
.id(id)
.title("Brief")
.originalFilename("brief.pdf")
.status(DocumentStatus.UPLOADED)
.thumbnailKey("thumbnails/" + id + ".jpg")
.thumbnailGeneratedAt(generatedAt)
.build();
// frontend equivalent: `?v=${encodeURIComponent(doc.thumbnailGeneratedAt)}`
// where thumbnailGeneratedAt is the ISO-8601 string Jackson serialises.
// LocalDateTime.toString() produces "2026-04-23T14:30:45"; encodeURIComponent
// turns ":" into "%3A" but leaves "T" and digits alone.
String expected = "/api/documents/" + id + "/thumbnail?v=2026-04-23T14%3A30%3A45";
assertThat(doc.getThumbnailUrl()).isEqualTo(expected);
}
@Test
void thumbnailUrl_isSerialisedToJson_soFrontendReceivesIt() throws Exception {
UUID id = UUID.fromString("99999999-aaaa-bbbb-cccc-111122223333");
Document doc = Document.builder()
.id(id)
.title("Brief")
.originalFilename("brief.pdf")
.status(DocumentStatus.UPLOADED)
.thumbnailKey("thumbnails/" + id + ".jpg")
.thumbnailGeneratedAt(LocalDateTime.of(2026, 4, 23, 9, 0, 0))
.build();
ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule());
String json = mapper.writeValueAsString(doc);
// Locks the wire contract, not just the Java API: every Document JSON must carry
// `thumbnailUrl`. Protects against silent breakage if the getter gets renamed,
// hidden behind @JsonIgnore, or visibility-reduced — any of which would leave the
// frontend rendering the fallback icon on every surface.
assertThat(json).contains("\"thumbnailUrl\":\"" + doc.getThumbnailUrl() + "\"");
}
}

View File

@@ -8,6 +8,7 @@ import org.raddatz.familienarchiv.model.DocumentAnnotation;
import org.raddatz.familienarchiv.model.DocumentStatus; import org.raddatz.familienarchiv.model.DocumentStatus;
import org.raddatz.familienarchiv.model.Person; import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.model.Tag; import org.raddatz.familienarchiv.model.Tag;
import org.raddatz.familienarchiv.model.ThumbnailAspect;
import org.raddatz.familienarchiv.model.TranscriptionBlock; import org.raddatz.familienarchiv.model.TranscriptionBlock;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase; import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
@@ -65,6 +66,40 @@ class DocumentRepositoryTest {
assertThat(found.get().getStatus()).isEqualTo(DocumentStatus.PLACEHOLDER); assertThat(found.get().getStatus()).isEqualTo(DocumentStatus.PLACEHOLDER);
} }
// ─── thumbnailAspect + pageCount round-trip ───────────────────────────────
@Test
void save_persistsThumbnailAspectAndPageCount() {
Document document = Document.builder()
.title("Mit Aspekt")
.originalFilename("aspect.pdf")
.status(DocumentStatus.UPLOADED)
.thumbnailAspect(ThumbnailAspect.LANDSCAPE)
.pageCount(7)
.build();
Document saved = documentRepository.save(document);
Document found = documentRepository.findById(saved.getId()).orElseThrow();
assertThat(found.getThumbnailAspect()).isEqualTo(ThumbnailAspect.LANDSCAPE);
assertThat(found.getPageCount()).isEqualTo(7);
}
@Test
void save_thumbnailAspectAndPageCount_defaultToNull() {
Document document = Document.builder()
.title("Ohne Aspekt")
.originalFilename("no_aspect.pdf")
.status(DocumentStatus.PLACEHOLDER)
.build();
Document saved = documentRepository.save(document);
Document found = documentRepository.findById(saved.getId()).orElseThrow();
assertThat(found.getThumbnailAspect()).isNull();
assertThat(found.getPageCount()).isNull();
}
// ─── findByStatus ───────────────────────────────────────────────────────── // ─── findByStatus ─────────────────────────────────────────────────────────
@Test @Test

View File

@@ -302,6 +302,51 @@ class MigrationIntegrationTest {
).isInstanceOf(DataIntegrityViolationException.class); ).isInstanceOf(DataIntegrityViolationException.class);
} }
// ─── V53: add thumbnail_aspect + page_count columns to documents ─────────
@Test
void v53_thumbnailAspectColumn_existsAndIsNullable() {
UUID docId = createDocument();
// Column must exist and accept NULL (freshly-created doc has no thumbnail yet)
String aspect = jdbc.queryForObject(
"SELECT thumbnail_aspect FROM documents WHERE id = ?", String.class, docId);
assertThat(aspect).isNull();
}
@Test
void v53_pageCountColumn_existsAndIsNullable() {
UUID docId = createDocument();
Integer pageCount = jdbc.queryForObject(
"SELECT page_count FROM documents WHERE id = ?", Integer.class, docId);
assertThat(pageCount).isNull();
}
@Test
void v53_thumbnailAspectColumn_acceptsPortraitAndLandscape() {
UUID docId = createDocument();
int portraitRows = jdbc.update(
"UPDATE documents SET thumbnail_aspect = 'PORTRAIT' WHERE id = ?", docId);
assertThat(portraitRows).isEqualTo(1);
int landscapeRows = jdbc.update(
"UPDATE documents SET thumbnail_aspect = 'LANDSCAPE' WHERE id = ?", docId);
assertThat(landscapeRows).isEqualTo(1);
}
@Test
void v53_pageCountColumn_storesInteger() {
UUID docId = createDocument();
jdbc.update("UPDATE documents SET page_count = 4 WHERE id = ?", docId);
Integer stored = jdbc.queryForObject(
"SELECT page_count FROM documents WHERE id = ?", Integer.class, docId);
assertThat(stored).isEqualTo(4);
}
// ─── V51: backfill annotation_id on block comments and notifications ───── // ─── V51: backfill annotation_id on block comments and notifications ─────
@Test @Test

View File

@@ -0,0 +1,137 @@
package org.raddatz.familienarchiv.service;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
import org.raddatz.familienarchiv.dto.DocumentSort;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentStatus;
import org.raddatz.familienarchiv.repository.DocumentRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.data.domain.PageRequest;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import software.amazon.awssdk.services.s3.S3Client;
import java.time.LocalDate;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
/**
* End-to-end paged search test with real PostgreSQL (Testcontainers). Covers the
* Specification→Pageable→Page→DTO path that unit tests mock around. Seeds 120
* UPLOADED documents and asserts the slice/total/totalPages arithmetic holds
* against the actual JPA query.
*
* <p>Closes the integration-coverage gap Sara flagged on PR #316.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@ActiveProfiles("test")
@Import(PostgresContainerConfig.class)
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
class DocumentSearchPagedIntegrationTest {
private static final int FIXTURE_SIZE = 120;
@MockitoBean S3Client s3Client;
@Autowired DocumentService documentService;
@Autowired DocumentRepository documentRepository;
@BeforeEach
void seed() {
// Deterministic date spread so DATE-DESC order is predictable:
// document #0 has the oldest date, document #119 has the newest.
for (int i = 0; i < FIXTURE_SIZE; i++) {
Document doc = Document.builder()
.title("Dok-" + String.format("%03d", i))
.originalFilename("dok-" + i + ".pdf")
.status(DocumentStatus.UPLOADED)
.documentDate(LocalDate.of(1900, 1, 1).plusDays(i))
.build();
documentRepository.save(doc);
}
assertThat(documentRepository.count()).isEqualTo(FIXTURE_SIZE);
}
@Test
void search_firstPage_returnsExactlyPageSizeItems_andCorrectTotalElements() {
DocumentSearchResult result = documentService.searchDocuments(
null, null, null, null, null, null, null, null,
DocumentSort.DATE, "DESC", null,
PageRequest.of(0, 50));
assertThat(result.items()).hasSize(50);
assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE);
assertThat(result.pageNumber()).isZero();
assertThat(result.pageSize()).isEqualTo(50);
assertThat(result.totalPages()).isEqualTo(3); // ceil(120 / 50)
}
@Test
void search_lastPartialPage_returnsRemainingItems() {
DocumentSearchResult result = documentService.searchDocuments(
null, null, null, null, null, null, null, null,
DocumentSort.DATE, "DESC", null,
PageRequest.of(2, 50));
// Page 2 (offset 100) of 120 docs → exactly 20 items on the tail.
assertThat(result.items()).hasSize(20);
assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE);
assertThat(result.pageNumber()).isEqualTo(2);
}
@Test
void search_pageBeyondLast_returnsEmptyContent_totalElementsStillCorrect() {
DocumentSearchResult result = documentService.searchDocuments(
null, null, null, null, null, null, null, null,
DocumentSort.DATE, "DESC", null,
PageRequest.of(99, 50));
assertThat(result.items()).isEmpty();
assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE);
}
@Test
void search_senderSort_pageOne_slicesInMemory_withCorrectTotal() {
// SENDER sort path fetches all + sorts + slices in-memory (see scaling
// comment in DocumentService). Proves that the in-memory slice path
// returns the correct total from a real repository fetch.
DocumentSearchResult result = documentService.searchDocuments(
null, null, null, null, null, null, null, null,
DocumentSort.SENDER, "asc", null,
PageRequest.of(1, 50));
assertThat(result.items()).hasSize(50);
assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE);
assertThat(result.pageNumber()).isEqualTo(1);
assertThat(result.totalPages()).isEqualTo(3);
}
@Test
void search_differentPagesReturnDisjointSlices() {
DocumentSearchResult page0 = documentService.searchDocuments(
null, null, null, null, null, null, null, null,
DocumentSort.DATE, "DESC", null,
PageRequest.of(0, 50));
DocumentSearchResult page1 = documentService.searchDocuments(
null, null, null, null, null, null, null, null,
DocumentSort.DATE, "DESC", null,
PageRequest.of(1, 50));
// No document id should appear on both pages — slicing must be exclusive.
var idsOnPage0 = page0.items().stream()
.map(item -> item.document().getId())
.toList();
var idsOnPage1 = page1.items().stream()
.map(item -> item.document().getId())
.toList();
for (UUID id : idsOnPage0) {
assertThat(idsOnPage1).doesNotContain(id);
}
}
}

View File

@@ -11,7 +11,8 @@ import org.raddatz.familienarchiv.dto.DocumentSort;
import org.raddatz.familienarchiv.model.Document; import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentStatus; import org.raddatz.familienarchiv.model.DocumentStatus;
import org.raddatz.familienarchiv.repository.DocumentRepository; import org.raddatz.familienarchiv.repository.DocumentRepository;
import org.springframework.data.domain.Sort; import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specification; import org.springframework.data.jpa.domain.Specification;
import java.time.LocalDate; import java.time.LocalDate;
@@ -25,6 +26,8 @@ import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
class DocumentServiceSortTest { class DocumentServiceSortTest {
private static final Pageable UNPAGED = org.springframework.data.domain.PageRequest.of(0, 10_000);
@Mock DocumentRepository documentRepository; @Mock DocumentRepository documentRepository;
@Mock PersonService personService; @Mock PersonService personService;
@Mock FileService fileService; @Mock FileService fileService;
@@ -51,12 +54,12 @@ class DocumentServiceSortTest {
// FTS returns id1 first (higher rank), id2 second // FTS returns id1 first (higher rank), id2 second
when(documentRepository.findRankedIdsByFts("Brief")).thenReturn(List.of(id1, id2)); when(documentRepository.findRankedIdsByFts("Brief")).thenReturn(List.of(id1, id2));
// findAll(spec, sort) — the correct date path — returns date-DESC order // findAll(spec, pageable) — the correct date path — returns date-DESC order
when(documentRepository.findAll(any(Specification.class), any(Sort.class))) when(documentRepository.findAll(any(Specification.class), any(Pageable.class)))
.thenReturn(List.of(newer, older)); .thenReturn(new PageImpl<>(List.of(newer, older)));
DocumentSearchResult result = documentService.searchDocuments( DocumentSearchResult result = documentService.searchDocuments(
"Brief", null, null, null, null, null, null, null, DocumentSort.DATE, "DESC", null); "Brief", null, null, null, null, null, null, null, DocumentSort.DATE, "DESC", null, UNPAGED);
// Expect: date order (newer 1960 first), NOT rank order (older 1940 first) // Expect: date order (newer 1960 first), NOT rank order (older 1940 first)
assertThat(result.items()).hasSize(2); assertThat(result.items()).hasSize(2);
@@ -78,7 +81,7 @@ class DocumentServiceSortTest {
.thenReturn(List.of(doc2, doc1)); // unordered from DB .thenReturn(List.of(doc2, doc1)); // unordered from DB
DocumentSearchResult result = documentService.searchDocuments( DocumentSearchResult result = documentService.searchDocuments(
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null); "Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, UNPAGED);
// Expect: rank order restored (id1 first) // Expect: rank order restored (id1 first)
assertThat(result.items().get(0).document().getId()).isEqualTo(id1); assertThat(result.items().get(0).document().getId()).isEqualTo(id1);
@@ -97,7 +100,7 @@ class DocumentServiceSortTest {
.thenReturn(List.of(doc2, doc1)); .thenReturn(List.of(doc2, doc1));
DocumentSearchResult result = documentService.searchDocuments( DocumentSearchResult result = documentService.searchDocuments(
"Brief", null, null, null, null, null, null, null, null, null, null); "Brief", null, null, null, null, null, null, null, null, null, null, UNPAGED);
assertThat(result.items().get(0).document().getId()).isEqualTo(id1); assertThat(result.items().get(0).document().getId()).isEqualTo(id1);
} }

View File

@@ -16,6 +16,7 @@ import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO; import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO;
import org.raddatz.familienarchiv.dto.MatchOffset; import org.raddatz.familienarchiv.dto.MatchOffset;
import org.raddatz.familienarchiv.dto.SearchMatchData; import org.raddatz.familienarchiv.dto.SearchMatchData;
import org.raddatz.familienarchiv.dto.TagOperator;
import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.model.Document; import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentStatus; import org.raddatz.familienarchiv.model.DocumentStatus;
@@ -24,6 +25,7 @@ import org.raddatz.familienarchiv.model.Tag;
import org.raddatz.familienarchiv.repository.DocumentRepository; import org.raddatz.familienarchiv.repository.DocumentRepository;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort;
import org.springframework.mock.web.MockMultipartFile; import org.springframework.mock.web.MockMultipartFile;
@@ -46,6 +48,12 @@ import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
class DocumentServiceTest { class DocumentServiceTest {
// Used by tests that don't care about paging. 10 000 is chosen large enough
// to hold any fixture in this file but small enough that totalPages math
// stays in int range. Swap to `PageRequest.of(0, 10_000)` elsewhere is a
// red flag — use this constant.
private static final Pageable UNPAGED = PageRequest.of(0, 10_000);
@Mock DocumentRepository documentRepository; @Mock DocumentRepository documentRepository;
@Mock PersonService personService; @Mock PersonService personService;
@Mock FileService fileService; @Mock FileService fileService;
@@ -1323,26 +1331,124 @@ class DocumentServiceTest {
assertThat(result).isNull(); assertThat(result).isNull();
} }
// ─── searchDocuments — pagination ────────────────────────────────────────
@Test
void searchDocuments_fastPath_usesFindAllWithPageable_notWithSort() {
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
.thenReturn(new PageImpl<>(List.of()));
documentService.searchDocuments(null, null, null, null, null, null, null, null,
org.raddatz.familienarchiv.dto.DocumentSort.DATE, "DESC", null,
org.springframework.data.domain.PageRequest.of(1, 50));
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class));
verify(documentRepository, never()).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class));
}
@Test
void searchDocuments_fastPath_propagatesPageableToDatabase() {
ArgumentCaptor<Pageable> captor = ArgumentCaptor.forClass(Pageable.class);
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
.thenReturn(new PageImpl<>(List.of()));
documentService.searchDocuments(null, null, null, null, null, null, null, null,
org.raddatz.familienarchiv.dto.DocumentSort.DATE, "DESC", null,
org.springframework.data.domain.PageRequest.of(3, 25));
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), captor.capture());
assertThat(captor.getValue().getPageNumber()).isEqualTo(3);
assertThat(captor.getValue().getPageSize()).isEqualTo(25);
}
@Test
void searchDocuments_fastPath_returnsPageableTotalsOnResult() {
// The service MUST report the full match count from Page.getTotalElements(),
// not the slice size — otherwise the frontend's "N Briefe gefunden" label is wrong.
Document d = Document.builder().id(UUID.randomUUID()).title("T").build();
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
.thenReturn(new PageImpl<>(List.of(d), org.springframework.data.domain.PageRequest.of(0, 50), 120L));
DocumentSearchResult result = documentService.searchDocuments(null, null, null, null, null, null, null, null,
org.raddatz.familienarchiv.dto.DocumentSort.DATE, "DESC", null,
org.springframework.data.domain.PageRequest.of(0, 50));
assertThat(result.totalElements()).isEqualTo(120L);
assertThat(result.pageNumber()).isZero();
assertThat(result.pageSize()).isEqualTo(50);
assertThat(result.totalPages()).isEqualTo(3); // ceil(120/50)
assertThat(result.items()).hasSize(1); // only the slice is enriched
}
@Test
void searchDocuments_senderSort_slicesInMemoryAndReportsFullTotal() {
// Fixture: 120 docs with senders; request page 1, size 50 → expect 50 items
// back with totalElements = 120.
List<Document> all = new java.util.ArrayList<>();
for (int i = 0; i < 120; i++) {
Person p = Person.builder()
.id(UUID.randomUUID())
.firstName("F" + i)
.lastName(String.format("L%03d", i))
.build();
all.add(Document.builder().id(UUID.randomUUID()).title("D" + i).sender(p).build());
}
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
.thenReturn(all);
DocumentSearchResult result = documentService.searchDocuments(null, null, null, null, null, null, null, null,
org.raddatz.familienarchiv.dto.DocumentSort.SENDER, "asc", null,
org.springframework.data.domain.PageRequest.of(1, 50));
assertThat(result.totalElements()).isEqualTo(120L);
assertThat(result.pageNumber()).isEqualTo(1);
assertThat(result.pageSize()).isEqualTo(50);
assertThat(result.totalPages()).isEqualTo(3);
assertThat(result.items()).hasSize(50);
// Page 1 (offset 50) under ascending sender sort should start at L050
assertThat(result.items().get(0).document().getSender().getLastName()).isEqualTo("L050");
}
@Test
void searchDocuments_pageBeyondLast_returnsEmptyContentAndCorrectTotal() {
// Guards the JPA edge case where page * size > totalElements.
// Must not throw, must return empty content + correct totalElements.
List<Document> all = new java.util.ArrayList<>();
for (int i = 0; i < 30; i++) {
Person p = Person.builder().id(UUID.randomUUID()).lastName(String.format("L%02d", i)).build();
all.add(Document.builder().id(UUID.randomUUID()).title("D" + i).sender(p).build());
}
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
.thenReturn(all);
DocumentSearchResult result = documentService.searchDocuments(null, null, null, null, null, null, null, null,
org.raddatz.familienarchiv.dto.DocumentSort.SENDER, "asc", null,
org.springframework.data.domain.PageRequest.of(10, 50));
assertThat(result.items()).isEmpty();
assertThat(result.totalElements()).isEqualTo(30L);
}
// ─── searchDocuments — status filter ───────────────────────────────────── // ─── searchDocuments — status filter ─────────────────────────────────────
@Test @Test
void searchDocuments_passesStatusSpecificationToRepository() { void searchDocuments_passesStatusSpecificationToRepository() {
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class))) when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
.thenReturn(List.of()); .thenReturn(new PageImpl<>(List.of()));
documentService.searchDocuments(null, null, null, null, null, null, null, DocumentStatus.REVIEWED, null, null, null); documentService.searchDocuments(null, null, null, null, null, null, null, DocumentStatus.REVIEWED, null, null, null, UNPAGED);
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class)); verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class));
} }
@Test @Test
void searchDocuments_withNullStatus_doesNotFilterByStatus() { void searchDocuments_withNullStatus_doesNotFilterByStatus() {
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class))) when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
.thenReturn(List.of()); .thenReturn(new PageImpl<>(List.of()));
documentService.searchDocuments(null, null, null, null, null, null, null, null, null, null, null); documentService.searchDocuments(null, null, null, null, null, null, null, null, null, null, null, UNPAGED);
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class)); verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class));
} }
// ─── getRecentActivity ──────────────────────────────────────────────────── // ─── getRecentActivity ────────────────────────────────────────────────────
@@ -1418,7 +1524,7 @@ class DocumentServiceTest {
.thenReturn(List.of(withSender, noSender)); .thenReturn(List.of(withSender, noSender));
DocumentSearchResult result = documentService.searchDocuments( DocumentSearchResult result = documentService.searchDocuments(
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null); null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null, UNPAGED);
assertThat(result.items()).hasSize(2); assertThat(result.items()).hasSize(2);
assertThat(result.items()).extracting(item -> item.document().getTitle()).containsExactly("Has Sender", "No Sender"); assertThat(result.items()).extracting(item -> item.document().getTitle()).containsExactly("Has Sender", "No Sender");
@@ -1438,7 +1544,7 @@ class DocumentServiceTest {
.thenReturn(List.of(noReceivers, withReceiver)); .thenReturn(List.of(noReceivers, withReceiver));
DocumentSearchResult result = documentService.searchDocuments( DocumentSearchResult result = documentService.searchDocuments(
null, null, null, null, null, null, null, null, DocumentSort.RECEIVER, "asc", null); null, null, null, null, null, null, null, null, DocumentSort.RECEIVER, "asc", null, UNPAGED);
assertThat(result.items()).extracting(item -> item.document().getTitle()) assertThat(result.items()).extracting(item -> item.document().getTitle())
.containsExactly("Has Receiver", "No Receivers"); .containsExactly("Has Receiver", "No Receivers");
@@ -1460,7 +1566,7 @@ class DocumentServiceTest {
.thenReturn(List.of(docNullName, docSmith)); .thenReturn(List.of(docNullName, docSmith));
DocumentSearchResult result = documentService.searchDocuments( DocumentSearchResult result = documentService.searchDocuments(
null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null); null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null, UNPAGED);
// null lastName should sort to end (treated as empty), not before "smith" (as "null") // null lastName should sort to end (treated as empty), not before "smith" (as "null")
assertThat(result.items()).extracting(item -> item.document().getTitle()) assertThat(result.items()).extracting(item -> item.document().getTitle())
@@ -1482,7 +1588,7 @@ class DocumentServiceTest {
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows); when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
DocumentSearchResult result = documentService.searchDocuments( DocumentSearchResult result = documentService.searchDocuments(
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null); "Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, UNPAGED);
assertThat(result.items()).hasSize(1); assertThat(result.items()).hasSize(1);
SearchMatchData md = result.items().get(0).matchData(); SearchMatchData md = result.items().get(0).matchData();
@@ -1492,11 +1598,12 @@ class DocumentServiceTest {
@Test @Test
void searchDocuments_withoutTextQuery_returnsEmptyMatchData() { void searchDocuments_withoutTextQuery_returnsEmptyMatchData() {
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class))) when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Pageable.class)))
.thenReturn(List.of()); .thenReturn(new PageImpl<>(List.of()));
DocumentSearchResult result = documentService.searchDocuments( DocumentSearchResult result = documentService.searchDocuments(
null, null, null, null, null, null, null, null, null, null, null); null, null, null, null, null, null, null, null, null, null, null,
UNPAGED);
assertThat(result.items()).isEmpty(); assertThat(result.items()).isEmpty();
} }
@@ -1515,7 +1622,7 @@ class DocumentServiceTest {
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows); when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
DocumentSearchResult result = documentService.searchDocuments( DocumentSearchResult result = documentService.searchDocuments(
"Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null); "Brief", null, null, null, null, null, null, null, DocumentSort.RELEVANCE, null, null, UNPAGED);
SearchMatchData md = result.items().get(0).matchData(); SearchMatchData md = result.items().get(0).matchData();
assertThat(md.transcriptionSnippet()).isEqualTo("Hier ist der Brief aus Berlin"); assertThat(md.transcriptionSnippet()).isEqualTo("Hier ist der Brief aus Berlin");
@@ -1707,4 +1814,437 @@ class DocumentServiceTest {
verify(auditService).logAfterCommit(eq(AuditKind.FILE_UPLOADED), isNull(), eq(id), isNull()); verify(auditService).logAfterCommit(eq(AuditKind.FILE_UPLOADED), isNull(), eq(id), isNull());
} }
// ─── storeDocumentWithBatchMetadata ──────────────────────────────────────
private MockMultipartFile pdfFile(String name) {
return new MockMultipartFile("file", name, "application/pdf", new byte[]{1});
}
private void stubStoreDocument(String filename) throws Exception {
when(documentRepository.findFirstByOriginalFilename(filename)).thenReturn(Optional.empty());
when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("key", "hash"));
}
@Test
void storeDocumentWithBatchMetadata_appliesTitleByIndex() throws Exception {
stubStoreDocument("scan01.pdf");
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO meta = new org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO();
meta.setTitles(List.of("Erster Brief", "Zweiter Brief"));
DocumentService.StoreResult result = documentService.storeDocumentWithBatchMetadata(pdfFile("scan01.pdf"), meta, 0, null);
assertThat(result.document().getTitle()).isEqualTo("Erster Brief");
}
@Test
void storeDocumentWithBatchMetadata_resolvesSenderViaPersonService() throws Exception {
UUID senderId = UUID.randomUUID();
stubStoreDocument("scan02.pdf");
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
Person sender = Person.builder().id(senderId).firstName("Anna").build();
when(personService.getById(senderId)).thenReturn(sender);
org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO meta = new org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO();
meta.setSenderId(senderId);
DocumentService.StoreResult result = documentService.storeDocumentWithBatchMetadata(pdfFile("scan02.pdf"), meta, 0, null);
assertThat(result.document().getSender()).isEqualTo(sender);
}
@Test
void storeDocumentWithBatchMetadata_appliesTagsViaUpdateDocumentTags() throws Exception {
UUID docId = UUID.randomUUID();
when(documentRepository.findFirstByOriginalFilename("scan03.pdf")).thenReturn(Optional.empty());
when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("key", "hash"));
when(documentRepository.save(any())).thenAnswer(inv -> {
Document d = inv.getArgument(0);
if (d.getId() == null) d.setId(docId);
return d;
});
when(documentRepository.findById(docId)).thenAnswer(inv -> {
Document d = new Document();
d.setId(docId);
return Optional.of(d);
});
Tag tag = Tag.builder().id(UUID.randomUUID()).name("Familie").build();
when(tagService.findOrCreate("Familie")).thenReturn(tag);
org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO meta = new org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO();
meta.setTagNames(List.of("Familie"));
documentService.storeDocumentWithBatchMetadata(pdfFile("scan03.pdf"), meta, 0, null);
verify(tagService).findOrCreate("Familie");
}
@Test
void storeDocumentWithBatchMetadata_leavesTitle_whenIndexExceedsTitlesList() throws Exception {
stubStoreDocument("scan04.pdf");
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO meta = new org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO();
meta.setTitles(List.of("Only One Title"));
DocumentService.StoreResult result = documentService.storeDocumentWithBatchMetadata(pdfFile("scan04.pdf"), meta, 5, null);
assertThat(result.document().getTitle()).isEqualTo("scan04");
}
// ─── validateBatch ───────────────────────────────────────────────────────
@Test
void validateBatch_throwsBatchTooLarge_whenFileCountExceedsCap() {
assertThatThrownBy(() -> documentService.validateBatch(51, null))
.isInstanceOf(DomainException.class)
.hasMessageContaining("50");
}
@Test
void validateBatch_doesNotThrow_whenFileCountEqualsCapExactly() {
documentService.validateBatch(50, null);
}
@Test
void validateBatch_throwsValidationError_whenTitlesSizeExceedsFileCount() {
org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO metadata =
new org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO();
metadata.setTitles(java.util.List.of("A", "B", "C"));
assertThatThrownBy(() -> documentService.validateBatch(2, metadata))
.isInstanceOf(DomainException.class)
.hasMessageContaining("titles");
}
// ─── applyBulkEditToDocument ─────────────────────────────────────────────
private static org.raddatz.familienarchiv.dto.DocumentBulkEditDTO bulkDto() {
return new org.raddatz.familienarchiv.dto.DocumentBulkEditDTO();
}
@Test
void applyBulkEditToDocument_throwsNotFound_whenDocumentMissing() {
UUID id = UUID.randomUUID();
when(documentRepository.findById(id)).thenReturn(Optional.empty());
assertThatThrownBy(() -> documentService.applyBulkEditToDocument(id, bulkDto(), null))
.isInstanceOf(DomainException.class)
.hasMessageContaining(id.toString());
}
@Test
void applyBulkEditToDocument_appliesTagsAdditively_preservesExistingTags() {
UUID id = UUID.randomUUID();
Tag existing = Tag.builder().id(UUID.randomUUID()).name("Brief").build();
Tag added = Tag.builder().id(UUID.randomUUID()).name("Kurrent").build();
Document doc = Document.builder().id(id).title("T")
.tags(new HashSet<>(Set.of(existing)))
.receivers(new HashSet<>())
.build();
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
when(tagService.findOrCreate("Kurrent")).thenReturn(added);
var dto = bulkDto();
dto.setTagNames(List.of("Kurrent"));
documentService.applyBulkEditToDocument(id, dto, null);
assertThat(doc.getTags()).containsExactlyInAnyOrder(existing, added);
}
@Test
void applyBulkEditToDocument_skipsTags_whenTagNamesIsNull() {
UUID id = UUID.randomUUID();
Tag existing = Tag.builder().id(UUID.randomUUID()).name("Brief").build();
Document doc = Document.builder().id(id).title("T")
.tags(new HashSet<>(Set.of(existing)))
.receivers(new HashSet<>())
.build();
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
documentService.applyBulkEditToDocument(id, bulkDto(), null);
assertThat(doc.getTags()).containsExactly(existing);
verify(tagService, never()).findOrCreate(any());
}
@Test
void applyBulkEditToDocument_skipsTags_whenTagNamesIsEmpty() {
UUID id = UUID.randomUUID();
Tag existing = Tag.builder().id(UUID.randomUUID()).name("Brief").build();
Document doc = Document.builder().id(id).title("T")
.tags(new HashSet<>(Set.of(existing)))
.receivers(new HashSet<>())
.build();
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
var dto = bulkDto();
dto.setTagNames(List.of());
documentService.applyBulkEditToDocument(id, dto, null);
assertThat(doc.getTags()).containsExactly(existing);
verify(tagService, never()).findOrCreate(any());
}
@Test
void applyBulkEditToDocument_replacesSender_whenSenderIdProvided() {
UUID id = UUID.randomUUID();
UUID senderId = UUID.randomUUID();
Person oldSender = Person.builder().id(UUID.randomUUID()).firstName("Old").build();
Person newSender = Person.builder().id(senderId).firstName("New").build();
Document doc = Document.builder().id(id).title("T")
.sender(oldSender)
.receivers(new HashSet<>())
.build();
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
when(personService.getById(senderId)).thenReturn(newSender);
var dto = bulkDto();
dto.setSenderId(senderId);
documentService.applyBulkEditToDocument(id, dto, null);
assertThat(doc.getSender()).isEqualTo(newSender);
}
@Test
void applyBulkEditToDocument_skipsSender_whenSenderIdIsNull() {
UUID id = UUID.randomUUID();
Person existing = Person.builder().id(UUID.randomUUID()).firstName("X").build();
Document doc = Document.builder().id(id).title("T")
.sender(existing)
.receivers(new HashSet<>())
.build();
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
documentService.applyBulkEditToDocument(id, bulkDto(), null);
assertThat(doc.getSender()).isEqualTo(existing);
verify(personService, never()).getById(any());
}
@Test
void applyBulkEditToDocument_addsReceiversAdditively_preservesExistingReceivers() {
UUID id = UUID.randomUUID();
UUID newReceiverId = UUID.randomUUID();
Person existing = Person.builder().id(UUID.randomUUID()).firstName("Old").build();
Person added = Person.builder().id(newReceiverId).firstName("New").build();
Document doc = Document.builder().id(id).title("T")
.receivers(new HashSet<>(Set.of(existing)))
.build();
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
when(personService.getAllById(List.of(newReceiverId))).thenReturn(List.of(added));
var dto = bulkDto();
dto.setReceiverIds(List.of(newReceiverId));
documentService.applyBulkEditToDocument(id, dto, null);
assertThat(doc.getReceivers()).containsExactlyInAnyOrder(existing, added);
}
@Test
void applyBulkEditToDocument_skipsReceivers_whenReceiverIdsIsNullOrEmpty() {
UUID id = UUID.randomUUID();
Person existing = Person.builder().id(UUID.randomUUID()).firstName("Old").build();
Document doc = Document.builder().id(id).title("T")
.receivers(new HashSet<>(Set.of(existing)))
.build();
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
var dto = bulkDto();
dto.setReceiverIds(List.of());
documentService.applyBulkEditToDocument(id, dto, null);
assertThat(doc.getReceivers()).containsExactly(existing);
verify(personService, never()).getAllById(any());
}
@Test
void applyBulkEditToDocument_recordsVersion_andLogsAuditEvent_taggedSourceBulkEdit() {
UUID id = UUID.randomUUID();
UUID actorId = UUID.randomUUID();
Document doc = Document.builder().id(id).title("T").receivers(new HashSet<>()).build();
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
when(documentRepository.save(any())).thenReturn(doc);
documentService.applyBulkEditToDocument(id, bulkDto(), actorId);
verify(documentVersionService).recordVersion(doc);
verify(auditService).logAfterCommit(
eq(AuditKind.METADATA_UPDATED),
eq(actorId),
eq(id),
eq(java.util.Map.of("source", "BULK_EDIT")));
}
@Test
void applyBulkEditToDocument_replacesArchiveBoxAndFolderAndDocumentLocation_whenProvided() {
UUID id = UUID.randomUUID();
Document doc = Document.builder().id(id).title("T")
.archiveBox("OldBox")
.archiveFolder("OldFolder")
.documentLocation("OldLocation")
.receivers(new HashSet<>())
.build();
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
var dto = bulkDto();
dto.setArchiveBox("NewBox");
dto.setArchiveFolder("NewFolder");
dto.setDocumentLocation("NewLocation");
documentService.applyBulkEditToDocument(id, dto, null);
assertThat(doc.getArchiveBox()).isEqualTo("NewBox");
assertThat(doc.getArchiveFolder()).isEqualTo("NewFolder");
assertThat(doc.getDocumentLocation()).isEqualTo("NewLocation");
}
@Test
void applyBulkEditToDocument_propagatesDomainException_whenSenderIdUnresolvable() {
// Sara C1 — unresolvable sender flows up as a per-document error chip
// rather than aborting the controller's batch loop.
UUID id = UUID.randomUUID();
UUID unknownSender = UUID.randomUUID();
Document doc = Document.builder().id(id).title("T").receivers(new HashSet<>()).build();
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
when(personService.getById(unknownSender))
.thenThrow(DomainException.notFound(
org.raddatz.familienarchiv.exception.ErrorCode.PERSON_NOT_FOUND,
"Person not found: " + unknownSender));
var dto = bulkDto();
dto.setSenderId(unknownSender);
assertThatThrownBy(() -> documentService.applyBulkEditToDocument(id, dto, null))
.isInstanceOf(DomainException.class)
.hasMessageContaining(unknownSender.toString());
}
// ─── findIdsForFilter ────────────────────────────────────────────────────
@Test
void findIdsForFilter_returnsAllMatchingIds_uncapped() {
Document d1 = Document.builder().id(UUID.randomUUID()).title("A").build();
Document d2 = Document.builder().id(UUID.randomUUID()).title("B").build();
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
.thenReturn(List.of(d1, d2));
List<UUID> result = documentService.findIdsForFilter(
null, null, null, null, null, null, null, null, null);
assertThat(result).containsExactly(d1.getId(), d2.getId());
}
@Test
void findIdsForFilter_passesTagOperatorOR_throughBuildSearchSpec() {
// Sara C3 — tagOp=OR flips useOrLogic at the spec layer; without a
// test pinning this, a refactor that wired OR to AND (or vice versa)
// would slip through.
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class)))
.thenReturn(List.of());
when(tagService.expandTagNamesToDescendantIdSets(any())).thenReturn(List.of());
documentService.findIdsForFilter(
null, null, null, null, null, List.of("Brief"), null, null, TagOperator.OR);
// Spec built without throwing → OR branch was exercised. Coverage gain
// is in not-throwing on the OR-specific code path; the actual SQL is
// covered by JPA itself.
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class));
}
@Test
void findIdsForFilter_returnsEmpty_whenFtsHasNoMatches() {
when(documentRepository.findRankedIdsByFts("xyz")).thenReturn(List.of());
List<UUID> result = documentService.findIdsForFilter(
"xyz", null, null, null, null, null, null, null, null);
assertThat(result).isEmpty();
verify(documentRepository, never()).findAll(any(org.springframework.data.jpa.domain.Specification.class));
}
// ─── batchMetadata ───────────────────────────────────────────────────────
@Test
void batchMetadata_returnsEmpty_whenIdsIsNull() {
assertThat(documentService.batchMetadata(null)).isEmpty();
}
@Test
void batchMetadata_returnsEmpty_whenIdsIsEmpty() {
assertThat(documentService.batchMetadata(List.of())).isEmpty();
}
@Test
void batchMetadata_returnsSummariesWithPdfUrl_forExistingIds() {
UUID id1 = UUID.randomUUID();
UUID id2 = UUID.randomUUID();
Document d1 = Document.builder().id(id1).title("Brief 1").build();
Document d2 = Document.builder().id(id2).title("Brief 2").build();
when(documentRepository.findAllById(List.of(id1, id2))).thenReturn(List.of(d1, d2));
var result = documentService.batchMetadata(List.of(id1, id2));
assertThat(result).hasSize(2);
assertThat(result.get(0).id()).isEqualTo(id1);
assertThat(result.get(0).title()).isEqualTo("Brief 1");
assertThat(result.get(0).pdfUrl()).isEqualTo("/api/documents/" + id1 + "/file");
}
@Test
void batchMetadata_silentlyDropsUnknownIds() {
UUID known = UUID.randomUUID();
UUID missing = UUID.randomUUID();
Document d = Document.builder().id(known).title("Found").build();
when(documentRepository.findAllById(List.of(known, missing))).thenReturn(List.of(d));
var result = documentService.batchMetadata(List.of(known, missing));
assertThat(result).hasSize(1);
assertThat(result.get(0).id()).isEqualTo(known);
}
@Test
void batchMetadata_fallsBackToOriginalFilename_whenTitleIsNull() {
UUID id = UUID.randomUUID();
Document d = Document.builder().id(id).originalFilename("scan001.pdf").build();
when(documentRepository.findAllById(List.of(id))).thenReturn(List.of(d));
var result = documentService.batchMetadata(List.of(id));
assertThat(result.get(0).title()).isEqualTo("scan001.pdf");
}
@Test
void applyBulkEditToDocument_skipsLocationFields_whenBlankOrNull() {
UUID id = UUID.randomUUID();
Document doc = Document.builder().id(id).title("T")
.archiveBox("KeepBox")
.archiveFolder("KeepFolder")
.documentLocation("KeepLocation")
.receivers(new HashSet<>())
.build();
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
var dto = bulkDto();
dto.setArchiveBox(" ");
dto.setArchiveFolder("");
// documentLocation left null
documentService.applyBulkEditToDocument(id, dto, null);
assertThat(doc.getArchiveBox()).isEqualTo("KeepBox");
assertThat(doc.getArchiveFolder()).isEqualTo("KeepFolder");
assertThat(doc.getDocumentLocation()).isEqualTo("KeepLocation");
}
} }

View File

@@ -11,6 +11,7 @@ import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor; import org.mockito.ArgumentCaptor;
import org.raddatz.familienarchiv.model.Document; import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentStatus; import org.raddatz.familienarchiv.model.DocumentStatus;
import org.raddatz.familienarchiv.model.ThumbnailAspect;
import org.raddatz.familienarchiv.repository.DocumentRepository; import org.raddatz.familienarchiv.repository.DocumentRepository;
import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.util.ReflectionTestUtils;
import software.amazon.awssdk.core.sync.RequestBody; import software.amazon.awssdk.core.sync.RequestBody;
@@ -167,6 +168,116 @@ class ThumbnailServiceTest {
verify(documentRepository, never()).save(any()); verify(documentRepository, never()).save(any());
} }
@Test
void generate_persistsPageCount_ofOne_forSingleImageUpload() throws IOException {
// Image uploads are always a single page from the user's perspective.
Document doc = makeDoc("image/png", "documents/scan.png");
when(fileService.downloadFileStream(anyString()))
.thenReturn(new ByteArrayInputStream(createSamplePng(600, 800)));
ThumbnailService.Outcome outcome = thumbnailService.generate(doc);
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SUCCESS);
assertThat(doc.getPageCount()).isEqualTo(1);
}
@Test
void generate_persistsPageCount_fromPdfDocument() throws IOException {
Document doc = makeDoc("application/pdf", "documents/multi.pdf");
when(fileService.downloadFileStream(anyString()))
.thenReturn(new ByteArrayInputStream(createSamplePdf(3)));
ThumbnailService.Outcome outcome = thumbnailService.generate(doc);
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SUCCESS);
assertThat(doc.getPageCount()).isEqualTo(3);
}
@Test
void generate_persistsPortraitAspect_forTypicalPortraitSourceImage() throws IOException {
// 600x800 → ratio w/h = 0.75 → below 1.1 threshold → PORTRAIT.
Document doc = makeDoc("image/png", "documents/portrait.png");
when(fileService.downloadFileStream(anyString()))
.thenReturn(new ByteArrayInputStream(createSamplePng(600, 800)));
ThumbnailService.Outcome outcome = thumbnailService.generate(doc);
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SUCCESS);
assertThat(doc.getThumbnailAspect()).isEqualTo(ThumbnailAspect.PORTRAIT);
}
@Test
void generate_persistsLandscapeAspect_whenWidthIsWellAboveHeight() throws IOException {
// 800x400 → ratio 2.0 → clearly above 1.1 → LANDSCAPE.
Document doc = makeDoc("image/jpeg", "documents/wide.jpg");
when(fileService.downloadFileStream(anyString()))
.thenReturn(new ByteArrayInputStream(createSampleJpeg(800, 400)));
ThumbnailService.Outcome outcome = thumbnailService.generate(doc);
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SUCCESS);
assertThat(doc.getThumbnailAspect()).isEqualTo(ThumbnailAspect.LANDSCAPE);
}
@Test
void generate_persistsPortraitAspect_whenSquareImage_belowLandscapeThreshold() throws IOException {
// 500x500 → ratio 1.0 → below 1.1 threshold → PORTRAIT (A4 scans often
// come in at near-square and we want them to live in the portrait tile).
Document doc = makeDoc("image/png", "documents/square.png");
when(fileService.downloadFileStream(anyString()))
.thenReturn(new ByteArrayInputStream(createSamplePng(500, 500)));
ThumbnailService.Outcome outcome = thumbnailService.generate(doc);
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SUCCESS);
assertThat(doc.getThumbnailAspect()).isEqualTo(ThumbnailAspect.PORTRAIT);
}
@Test
void generate_persistsPortraitAspect_justUnderLandscapeThreshold() throws IOException {
// 1099x1000 → ratio 1.099 → just under 1.1 threshold → PORTRAIT.
Document doc = makeDoc("image/png", "documents/near_threshold.png");
when(fileService.downloadFileStream(anyString()))
.thenReturn(new ByteArrayInputStream(createSamplePng(1099, 1000)));
ThumbnailService.Outcome outcome = thumbnailService.generate(doc);
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.SUCCESS);
assertThat(doc.getThumbnailAspect()).isEqualTo(ThumbnailAspect.PORTRAIT);
}
@Test
void generate_returnsFailed_whenImageBytesAreCorrupt() throws IOException {
// Truncated JPEG header — ImageIO returns null rather than throwing.
// Without the corrupt-image guard this would later NPE inside the aspect /
// dimension computation in scaleToWidth.
Document doc = makeDoc("image/jpeg", "documents/corrupt.jpg");
byte[] truncated = new byte[]{(byte) 0xFF, (byte) 0xD8, (byte) 0xFF, (byte) 0xE0};
when(fileService.downloadFileStream(anyString()))
.thenReturn(new ByteArrayInputStream(truncated));
ThumbnailService.Outcome outcome = thumbnailService.generate(doc);
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.FAILED);
verifyNoInteractions(s3Client);
verify(documentRepository, never()).save(any());
}
@Test
void generate_returnsFailed_whenPdfBytesAreCorrupt() throws IOException {
// "PDF" header but no body — PDFBox throws IOException while loading.
Document doc = makeDoc("application/pdf", "documents/corrupt.pdf");
byte[] fakePdf = "%PDF-1.4\n".getBytes();
when(fileService.downloadFileStream(anyString()))
.thenReturn(new ByteArrayInputStream(fakePdf));
ThumbnailService.Outcome outcome = thumbnailService.generate(doc);
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.FAILED);
verifyNoInteractions(s3Client);
verify(documentRepository, never()).save(any());
}
@Test @Test
void generate_returnsFailed_whenPersistThrows_butUploadSucceeded() throws IOException { void generate_returnsFailed_whenPersistThrows_butUploadSucceeded() throws IOException {
// Covers the "orphan thumbnail" edge case: S3 upload succeeded but the // Covers the "orphan thumbnail" edge case: S3 upload succeeded but the
@@ -202,15 +313,21 @@ class ThumbnailServiceTest {
} }
private static byte[] createSamplePdf() throws IOException { private static byte[] createSamplePdf() throws IOException {
return createSamplePdf(1);
}
private static byte[] createSamplePdf(int pageCount) throws IOException {
try (PDDocument doc = new PDDocument()) { try (PDDocument doc = new PDDocument()) {
PDPage page = new PDPage(PDRectangle.A4); for (int i = 0; i < pageCount; i++) {
doc.addPage(page); PDPage page = new PDPage(PDRectangle.A4);
try (PDPageContentStream content = new PDPageContentStream(doc, page)) { doc.addPage(page);
content.beginText(); try (PDPageContentStream content = new PDPageContentStream(doc, page)) {
content.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 24); content.beginText();
content.newLineAtOffset(100, 700); content.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 24);
content.showText("Lieber Hans,"); content.newLineAtOffset(100, 700);
content.endText(); content.showText("Lieber Hans,");
content.endText();
}
} }
ByteArrayOutputStream bos = new ByteArrayOutputStream(); ByteArrayOutputStream bos = new ByteArrayOutputStream();
doc.save(bos); doc.save(bos);

View File

@@ -0,0 +1,52 @@
# ADR-005: thumbnailAspect + pageCount alongside the thumbnail
## Status
Accepted
## Context
Issue #305 rebalances the /briefwechsel correspondence list into PDF-thumbnail rows. Two pieces of metadata are needed at row-render time:
- **Aspect ratio** — postcards are landscape (7:5), letters are portrait (5:7). Forcing landscape scans into a portrait tile crops away the signature; forcing portrait scans into a landscape tile wastes horizontal real estate.
- **Page count** — multi-page letters should show a "N" badge on their thumbnail so the reader can tell a single-page note from a seven-page letter without clicking in.
Both values are cheap to derive at the point the thumbnail is generated (the source image is already decoded; the PDF is already loaded) and impossible to derive cheaply later (requires re-reading the S3 object).
## Decision
Persist both values as columns on `documents` and populate them inside `ThumbnailService.generate()` — the same code path that writes the JPEG to S3 and stamps `thumbnail_generated_at`.
- `thumbnail_aspect VARCHAR(16)` mapped to a Java enum `ThumbnailAspect` with two values: `PORTRAIT`, `LANDSCAPE`.
- `page_count INTEGER``PDDocument.getNumberOfPages()` for PDFs, `1` for image uploads.
- Aspect threshold is `source.width / source.height > 1.1``LANDSCAPE`; everything else (including near-square A4 scans at ratio ≈ 1.0) stays `PORTRAIT`. The 1.1 margin keeps borderline scans from flipping across the threshold on a rounding error.
- Both columns are nullable and remain `null` for historical documents until the existing `/api/admin/generate-thumbnails` backfill rerun populates them.
## Alternatives Considered
| Alternative | Why rejected |
|---|---|
| Derive aspect client-side after image load | First-paint would have all tiles in portrait, then reshuffle into landscape when the JPEG decodes — a visible jank on slow networks. The backend already has the dimensions; client-side recomputation is a waste. |
| Store full `width` / `height` columns | Not needed anywhere — consumers want the categorical answer. If a future feature needs exact dimensions, they can be added later without migrating existing rows. |
| A separate `thumbnail_metadata` table | Two scalar nullable columns aren't worth a join. See ADR-004 — thumbnails are modeled as a cross-cutting aspect of `Document`, not a sub-domain. |
| Derive page count from the existing PDF at render time on the frontend | Duplicates work already done on the backend and requires a separate byte-range fetch of the PDF header. Frontend already gets `pageCount` "for free" via the Document response. |
## Consequences
**Easier:**
- `ConversationThumbnail.svelte` picks the tile dimensions from `thumbnailAspect` directly — no async measurement, no layout shift.
- `ThumbnailRow` reads `pageCount` synchronously for the badge. Multi-page letters are distinguishable at first paint.
- Backfill runs the same migration path for every old document — re-executing generates the aspect + pageCount columns along with the JPEG, so operators don't have a second admin button to click.
**Harder:**
- Both columns are `null` for every document until the backfill runs on a given instance. Frontend components guard with `?? 'PORTRAIT'` / `?? 1` so the UI stays sensible during the rollout window. The backfill is idempotent and cheap (reuses existing S3 object), so re-running it is the simplest recovery path.
- The aspect threshold is a single constant in Java. A future need to tune per-type (e.g. postcards vs photos) means a code change, not a configuration change — acceptable for a single-operator archive.
### Ordering inside `ThumbnailService.generate()`
Aspect computation happens AFTER the JPEG upload succeeds but BEFORE the entity save — if the save throws, the columns rewind with it. Page count is captured while the `PDDocument` is still open; the `SourcePreview` record carries both the rendered first-page image and the page count back to the top of the pipeline so the PDF isn't reopened later.
## Future Direction
- If a postcard-specific "photo" chip is ever reintroduced, reuse `thumbnailAspect === 'LANDSCAPE' && pageCount === 1` rather than adding a new `kind` column.
- If multi-size thumbnails are introduced (per ADR-004's future note), the aspect + pageCount are per-document and do not need to be duplicated per size.

View File

@@ -0,0 +1,996 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Bulk Upload — 3 Concept Designs · Familienarchiv</title>
<link href="https://fonts.googleapis.com/css2?family=Merriweather:ital,wght@0,300;0,400;0,700;1,300&family=Montserrat:wght@400;500;600;700;800;900&display=swap" rel="stylesheet"/>
<style>
/* ── Reset ── */
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Montserrat',system-ui,sans-serif;background:#ECEAE4;color:#1A1A1A;line-height:1.5;font-size:13px}
.doc{max-width:1300px;margin:0 auto;padding:48px 32px 120px}
/* ── Masthead ── */
.mh{padding-bottom:24px;border-bottom:3px solid #002850;margin-bottom:60px}
.mh .kicker{font-size:9px;font-weight:800;letter-spacing:2px;text-transform:uppercase;color:#A6DAD8}
.mh h1{font-size:28px;font-weight:900;color:#002850;letter-spacing:-.4px;margin-top:6px}
.mh p{font-size:13px;color:#555;max-width:780px;line-height:1.75;margin-top:10px}
.mh .byline{font-size:9px;color:#999;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;margin-top:14px}
.tag-row{display:flex;gap:6px;margin-top:10px;flex-wrap:wrap}
.tag{background:#002850;color:#A6DAD8;padding:3px 9px;border-radius:2px;font-size:8.5px;font-weight:700;letter-spacing:.8px;text-transform:uppercase}
.tag.amber{background:#7c4a00;color:#fde68a}
.tag.green{background:#1e5e34;color:#d1fae5}
.tag.gray{background:#4b5563;color:#e5e7eb}
.tag.mint{background:#A6DAD8;color:#002850}
/* ── Goals card ── */
.goals{background:#fff;border:1px solid #E4E2D7;border-radius:4px;padding:22px 26px;margin:0 0 60px;box-shadow:0 1px 3px rgba(0,0,0,.04)}
.goals h2{font-size:11px;font-weight:800;letter-spacing:1.5px;text-transform:uppercase;color:#002850;margin-bottom:14px}
.goals ul{list-style:none;display:grid;grid-template-columns:1fr 1fr;gap:10px 28px}
.goals li{font-size:12.5px;color:#333;padding-left:20px;position:relative;line-height:1.55}
.goals li::before{content:"→";position:absolute;left:0;top:0;color:#A6DAD8;font-weight:800}
/* ── Concept section ── */
.concept{margin-bottom:88px;padding-bottom:88px;border-bottom:2px dashed #C8C4BE}
.concept:last-of-type{border-bottom:none;margin-bottom:0;padding-bottom:0}
.concept-header{display:flex;align-items:flex-start;gap:24px;margin-bottom:36px}
.concept-num{font-size:84px;font-weight:900;color:#E0DDD6;line-height:1;flex-shrink:0;width:96px}
.concept-label{font-size:8.5px;font-weight:800;text-transform:uppercase;letter-spacing:1.2px;color:#A6DAD8;margin-bottom:5px}
.concept-title{font-family:'Merriweather',Georgia,serif;font-size:24px;font-weight:700;color:#002850;margin-bottom:10px}
.concept-desc{font-size:13.5px;color:#555;max-width:740px;line-height:1.75}
.concept-best{margin-top:14px;display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.best-label{background:#A6DAD8;color:#002850;padding:3px 9px;border-radius:2px;font-size:8.5px;font-weight:800;letter-spacing:.6px;text-transform:uppercase}
.best-text{font-size:12px;font-weight:600;color:#444}
.concept-tradeoff{margin-top:8px;font-size:12px;color:#888;font-style:italic;max-width:680px;line-height:1.7}
/* ── Browser chrome ── */
.screen{max-width:980px;margin:0 auto}
.screen.narrow{max-width:400px}
.chrome{background:#F5F4EE;border:1.5px solid #C4C0BA;border-radius:8px;overflow:hidden;box-shadow:0 4px 20px rgba(0,0,0,.1)}
.chrome-bar{height:22px;background:#E8E6E0;border-bottom:1px solid #C4C0BA;display:flex;align-items:center;padding:0 9px;gap:5px;flex-shrink:0}
.chrome-dot{width:7px;height:7px;border-radius:50%;background:#BDB8B1}
.chrome-url{flex:1;height:10px;background:#CCC8C2;border-radius:5px;margin-left:8px}
.viewport-hint{font-size:7.5px;font-weight:800;color:#A6DAD8;letter-spacing:1px;text-transform:uppercase;padding:4px 9px;background:#002850;border-radius:2px;margin-left:8px}
/* ── App nav ── */
.app-nav{height:32px;background:#002850;display:flex;align-items:center;padding:0 14px;gap:12px;flex-shrink:0}
.app-logo{font-family:'Merriweather',Georgia,serif;font-size:8px;font-weight:700;color:#fff;border-bottom:2px solid #A6DAD8;padding-bottom:1px}
.app-link{font-size:6px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;color:rgba(255,255,255,.45);white-space:nowrap}
.app-link.on{color:rgba(255,255,255,.9)}
.app-nav-r{margin-left:auto;display:flex;gap:8px;align-items:center}
.app-avatar{width:18px;height:18px;border-radius:50%;background:rgba(255,255,255,.12);display:flex;align-items:center;justify-content:center;font-size:6px;font-weight:800;color:rgba(255,255,255,.5)}
/* ── Common form element styles ── */
.f-label{font-size:6.5px;font-weight:700;color:#666;letter-spacing:.2px;text-transform:uppercase}
.f-req{color:#C0392B}
.f-input{height:20px;border:1px solid #D4D0CA;border-radius:2px;background:#fff;font-size:7.5px;padding:0 7px;color:#333;display:flex;align-items:center}
.f-input.focus{border-color:#002850;box-shadow:0 0 0 2px rgba(0,40,80,.12)}
.f-input.filled{color:#002850;font-weight:600;background:#FAFBFF}
.f-input.suggested{border-color:#A6DAD8;background:#F0FAFA;color:#005858;font-weight:600}
.f-input.empty{color:#BBB;font-style:italic}
.f-input.tall{height:28px}
.f-tags{display:flex;gap:3px;flex-wrap:wrap;min-height:20px;border:1px solid #D4D0CA;border-radius:2px;padding:2px 4px;background:#fff;align-items:center}
.f-chip{background:#002850;color:#A6DAD8;border-radius:2px;font-size:6px;font-weight:700;padding:1px 4px 1px 5px;display:flex;align-items:center;gap:2px}
.f-chip-rm{color:rgba(166,218,216,.5);font-weight:400}
/* ── Action bar ── */
.action-bar{height:46px;background:#F5F4EE;border-top:1px solid #E4E2D7;display:flex;align-items:center;padding:0 14px;gap:8px;flex-shrink:0}
.btn-skip{font-size:7px;font-weight:700;color:#AAA;letter-spacing:.2px;cursor:pointer}
.btn-spacer{flex:1}
.btn-outline{height:24px;padding:0 12px;border:1px solid #C0BDB6;border-radius:2px;font-size:6.5px;font-weight:800;text-transform:uppercase;letter-spacing:.5px;color:#777;display:flex;align-items:center;cursor:pointer;background:#fff}
.btn-primary{height:24px;padding:0 12px;border-radius:2px;font-size:6.5px;font-weight:800;text-transform:uppercase;letter-spacing:.5px;background:#002850;color:#fff;display:flex;align-items:center;cursor:pointer;gap:4px}
.btn-primary.green{background:#1A7040}
/* ─────────────────────────────────────── */
/* ── CONCEPT A — Stack (mobile-first) ── */
/* ─────────────────────────────────────── */
.ca-top-bar{height:34px;background:#F5F4EE;border-bottom:1px solid #E4E2D7;display:flex;align-items:center;padding:0 12px;gap:8px}
.ca-back{font-size:7px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#888}
.ca-title{flex:1;text-align:center;font-family:'Merriweather',Georgia,serif;font-size:9px;color:#002850;font-weight:600}
.ca-count{font-size:7px;font-weight:700;color:#002850;background:#A6DAD8;padding:2px 6px;border-radius:10px;letter-spacing:.3px}
.ca-body{background:#ECEAE4;padding:14px 12px;overflow-y:auto}
.ca-drop{background:#fff;border:2px dashed #A6DAD8;border-radius:4px;padding:14px;text-align:center;margin-bottom:14px}
.ca-drop-icon{font-size:18px;color:#A6DAD8;margin-bottom:4px}
.ca-drop-title{font-size:8.5px;font-weight:700;color:#002850;margin-bottom:2px}
.ca-drop-sub{font-size:6.5px;color:#999}
.ca-shared-card{background:#fff;border:1px solid #E4E2D7;border-radius:3px;padding:12px 14px;margin-bottom:14px;box-shadow:0 1px 2px rgba(0,0,0,.03)}
.ca-shared-head{display:flex;align-items:center;gap:6px;margin-bottom:11px}
.ca-shared-badge{background:#A6DAD8;color:#002850;padding:2px 7px;border-radius:2px;font-size:6px;font-weight:800;letter-spacing:.4px;text-transform:uppercase}
.ca-shared-title{font-family:'Merriweather',Georgia,serif;font-size:9.5px;color:#002850;font-weight:700}
.ca-shared-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px 10px}
.ca-shared-grid .full{grid-column:1/-1}
.ca-shared-field{display:flex;flex-direction:column;gap:3px}
.ca-files-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;padding:0 2px}
.ca-files-title{font-size:7px;font-weight:800;letter-spacing:1px;text-transform:uppercase;color:#B0ADA6}
.ca-files-add{font-size:7px;font-weight:700;color:#002850;display:flex;align-items:center;gap:3px}
.ca-file{background:#fff;border:1px solid #E4E2D7;border-radius:3px;padding:9px 10px;margin-bottom:7px;display:flex;align-items:center;gap:10px}
.ca-file.active{border-color:#002850;box-shadow:0 0 0 2px rgba(0,40,80,.08)}
.ca-thumb{width:28px;height:36px;background:#FFFEF8;border:1px solid #E4E2D7;border-radius:1px;flex-shrink:0;display:flex;flex-direction:column;padding:3px;gap:1px}
.ca-thumb .tl{height:2px;background:#C4BDB0;opacity:.6;border-radius:1px}
.ca-thumb .tl.s{width:60%;opacity:.35}
.ca-thumb .tl.m{width:82%}
.ca-file-body{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}
.ca-file-title{font-size:8px;color:#002850;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ca-file-title.placeholder{color:#888;font-weight:400;font-style:italic}
.ca-file-meta{font-size:6.5px;color:#AAA}
.ca-file-rm{font-size:10px;color:#B0ADA6;padding:0 4px;cursor:pointer}
/* ───────────────────────────────────────────── */
/* ── CONCEPT B — Split-panel + file switcher ── */
/* ───────────────────────────────────────────── */
.cb-top-bar{height:38px;background:#F5F4EE;border-bottom:1px solid #E4E2D7;display:flex;align-items:center;padding:0 14px;gap:10px}
.cb-back{font-size:7px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#888}
.cb-title{font-family:'Merriweather',Georgia,serif;font-size:9px;font-weight:700;color:#002850}
.cb-count{background:#A6DAD8;color:#002850;padding:2px 7px;border-radius:10px;font-size:7px;font-weight:800;letter-spacing:.3px}
.cb-discard{margin-left:auto;font-size:7px;font-weight:700;color:#C0392B;letter-spacing:.2px}
.cb-split{display:flex;min-height:440px}
.cb-pdf{flex:55;background:#5E5C59;display:flex;flex-direction:column;border-right:1px solid #3A3836}
.cb-pdf-toolbar{height:28px;background:#3A3836;display:flex;align-items:center;padding:0 10px;gap:8px}
.cb-pdf-btn{width:16px;height:16px;border-radius:2px;background:rgba(255,255,255,.1);display:flex;align-items:center;justify-content:center;font-size:7px;color:rgba(255,255,255,.6)}
.cb-pdf-page{font-size:6.5px;color:rgba(255,255,255,.4);margin-left:auto;font-weight:700;letter-spacing:.5px}
.cb-pdf-view{flex:1;display:flex;justify-content:center;padding:14px;overflow:hidden}
.cb-paper{background:#FFFEF8;box-shadow:0 2px 10px rgba(0,0,0,.3);border-radius:1px;padding:14px 16px;display:flex;flex-direction:column;gap:0;width:180px;flex-shrink:0}
.pl{height:4px;background:#C4BDB0;border-radius:1px;opacity:.55;margin-bottom:3px}
.pl.h{height:6px;opacity:.75;margin-bottom:5px}
.pl.s{width:55%;opacity:.3}
.pl.m{width:80%}
.pl.sp{height:7px;background:transparent}
.cb-filebar{background:#434140;border-top:1px solid #3A3836;display:flex;align-items:center;padding:0 8px;gap:3px;height:36px;flex-shrink:0}
.cb-fb-arrow{width:18px;height:22px;border-radius:2px;background:rgba(255,255,255,.08);display:flex;align-items:center;justify-content:center;font-size:9px;color:rgba(255,255,255,.6)}
.cb-fb-track{flex:1;display:flex;gap:3px;padding:0 3px;overflow:hidden}
.cb-fb-item{padding:3px 6px;border-radius:2px;font-size:6px;font-weight:700;color:rgba(255,255,255,.55);background:rgba(255,255,255,.06);display:flex;align-items:center;gap:4px;white-space:nowrap}
.cb-fb-item.on{background:#A6DAD8;color:#002850}
.cb-fb-num{background:rgba(0,0,0,.15);border-radius:2px;padding:0 3px;font-size:5.5px;font-weight:800}
.cb-fb-item.on .cb-fb-num{background:rgba(0,40,80,.25);color:#002850}
.cb-form{flex:45;background:#fff;display:flex;flex-direction:column}
.cb-form-scroll{flex:1;overflow-y:auto;padding:14px}
.cb-only-card{background:#F0FAFA;border:1px solid #A6DAD8;border-radius:3px;padding:10px 12px;margin-bottom:12px}
.cb-only-head{display:flex;align-items:center;gap:6px;margin-bottom:7px}
.cb-only-badge{background:#005858;color:#A6DAD8;padding:2px 7px;border-radius:2px;font-size:6px;font-weight:800;letter-spacing:.4px;text-transform:uppercase}
.cb-only-subtitle{font-size:6.5px;color:#005858;font-weight:600;letter-spacing:.3px}
.cb-shared-card{background:#F9F8F5;border:1px solid #E4E2D7;border-radius:3px;padding:10px 12px;margin-bottom:10px}
.cb-shared-head{display:flex;align-items:center;gap:6px;margin-bottom:9px}
.cb-shared-badge{background:#A6DAD8;color:#002850;padding:2px 7px;border-radius:2px;font-size:6px;font-weight:800;letter-spacing:.4px;text-transform:uppercase}
.cb-shared-subtitle{font-size:6.5px;color:#002850;font-weight:600}
.cb-row{display:grid;grid-template-columns:1fr 1fr;gap:7px;margin-bottom:7px}
.cb-row.full{grid-template-columns:1fr}
.cb-field{display:flex;flex-direction:column;gap:3px}
/* ─────────────────────────────────────── */
/* ── CONCEPT C — Progressive accordion ── */
/* ─────────────────────────────────────── */
.cc-top-bar{height:34px;background:#F5F4EE;border-bottom:1px solid #E4E2D7;display:flex;align-items:center;padding:0 14px;gap:8px}
.cc-body{background:#ECEAE4;padding:14px;display:flex;flex-direction:column;gap:11px;max-height:540px;overflow-y:auto}
.cc-shared{background:#fff;border:1px solid #E4E2D7;border-radius:3px;padding:12px 14px;box-shadow:0 1px 2px rgba(0,0,0,.03);position:sticky;top:0;z-index:2}
.cc-shared-head{display:flex;align-items:center;gap:7px;margin-bottom:11px}
.cc-shared-badge{background:#A6DAD8;color:#002850;padding:2px 7px;border-radius:2px;font-size:6px;font-weight:800;letter-spacing:.4px;text-transform:uppercase}
.cc-shared-title{font-family:'Merriweather',Georgia,serif;font-size:10px;color:#002850;font-weight:700}
.cc-grid{display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px 10px}
.cc-grid .span2{grid-column:span 2}
.cc-files-label{font-size:7px;font-weight:800;letter-spacing:1px;text-transform:uppercase;color:#B0ADA6;padding:0 2px;margin-top:6px}
.cc-file{background:#fff;border:1px solid #E4E2D7;border-radius:3px;overflow:hidden}
.cc-file.open{border-color:#002850;box-shadow:0 2px 6px rgba(0,40,80,.08)}
.cc-file-head{display:flex;align-items:center;gap:10px;padding:9px 12px;cursor:pointer}
.cc-file-head.open{border-bottom:1px solid #E4E2D7;background:#F9F8F5}
.cc-caret{font-size:9px;color:#A6DAD8;width:10px}
.cc-file-thumb{width:22px;height:28px;background:#FFFEF8;border:1px solid #E4E2D7;border-radius:1px;padding:2px;display:flex;flex-direction:column;gap:1px;flex-shrink:0}
.cc-file-thumb .tl{height:2px;background:#C4BDB0;opacity:.55;border-radius:1px}
.cc-file-body{flex:1;min-width:0}
.cc-file-titlerow{display:flex;align-items:center;gap:7px}
.cc-file-title{font-size:8.5px;color:#002850;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.cc-file-title.placeholder{color:#888;font-weight:400;font-style:italic}
.cc-file-meta{font-size:6.5px;color:#AAA;margin-top:2px}
.cc-file-rm{font-size:11px;color:#B0ADA6;padding:0 4px}
.cc-file-open{display:flex;background:#F5F4EE}
.cc-preview{flex:45;background:#5E5C59;padding:12px;display:flex;justify-content:center}
.cc-preview-paper{background:#FFFEF8;border-radius:1px;padding:8px 10px;width:110px;flex-shrink:0;display:flex;flex-direction:column;box-shadow:0 2px 6px rgba(0,0,0,.25)}
.cc-file-form{flex:55;padding:12px 14px;background:#fff;display:flex;flex-direction:column;gap:7px}
/* ─────────── Decision matrix ─────────── */
.decision{background:#fff;border:1px solid #E4E2D7;border-radius:4px;padding:28px 32px;margin:88px 0 60px;box-shadow:0 1px 3px rgba(0,0,0,.04)}
.decision h2{font-size:11px;font-weight:800;letter-spacing:1.5px;text-transform:uppercase;color:#002850;margin-bottom:6px}
.decision p.lead{font-size:13.5px;color:#555;line-height:1.7;margin-bottom:22px;max-width:820px}
.dm{width:100%;border-collapse:collapse;margin-top:12px;font-size:12px}
.dm th{text-align:left;font-size:9.5px;font-weight:800;letter-spacing:.5px;text-transform:uppercase;color:#002850;padding:9px 12px;background:#F9F8F5;border-bottom:2px solid #E4E2D7}
.dm td{padding:13px 12px;border-bottom:1px solid #EFEDE7;vertical-align:top;line-height:1.6}
.dm td:first-child{font-weight:700;color:#002850;width:18%;white-space:nowrap}
.dm td.score{font-size:15px;text-align:center;width:12%}
.dm td.ok{color:#1A7040}
.dm td.mid{color:#A07100}
.dm td.bad{color:#C0392B}
/* ─────────── Recommendation ─────────── */
.reco{background:#002850;color:#fff;border-radius:6px;padding:36px 40px;margin:48px 0 64px;box-shadow:0 4px 20px rgba(0,40,80,.15)}
.reco .kicker{font-size:9px;font-weight:800;letter-spacing:2px;text-transform:uppercase;color:#A6DAD8}
.reco h2{font-family:'Merriweather',Georgia,serif;font-size:26px;font-weight:700;margin-top:6px}
.reco .why{font-size:13.5px;line-height:1.85;color:rgba(255,255,255,.88);max-width:780px;margin-top:14px}
.reco ul{list-style:none;margin-top:14px;display:grid;grid-template-columns:1fr 1fr;gap:9px 26px}
.reco ul li{font-size:12.5px;color:rgba(255,255,255,.9);padding-left:22px;position:relative;line-height:1.6}
.reco ul li::before{content:"✓";position:absolute;left:0;top:0;color:#A6DAD8;font-weight:800}
/* ─────────── Impl-ref ─────────── */
.impl{background:#fff;border:1px solid #E4E2D7;border-radius:4px;padding:28px 32px;box-shadow:0 1px 3px rgba(0,0,0,.04)}
.impl h2{font-size:11px;font-weight:800;letter-spacing:1.5px;text-transform:uppercase;color:#002850;margin-bottom:16px}
.impl h3{font-family:'Merriweather',Georgia,serif;font-size:15px;color:#002850;margin:22px 0 10px}
.impl-table{width:100%;border-collapse:collapse;margin-top:6px;font-size:12px}
.impl-table th{text-align:left;font-size:9px;font-weight:800;letter-spacing:.6px;text-transform:uppercase;color:#002850;padding:8px 10px;background:#F9F8F5;border-bottom:2px solid #E4E2D7}
.impl-table td{padding:10px;border-bottom:1px solid #EFEDE7;vertical-align:top;line-height:1.55}
.impl-table td:first-child{font-weight:700;color:#002850;width:22%}
.impl-table td code{font-family:'SF Mono','Menlo',monospace;font-size:11px;background:#F0EEE8;padding:1px 6px;border-radius:2px;color:#002850}
.impl-table td.px{color:#777;font-size:11.5px;width:16%}
.impl-table td.note{color:#888;font-size:11.5px;font-style:italic;width:22%}
.impl h3.ix{margin-top:32px}
.notes{background:#F9F8F5;border-left:3px solid #A6DAD8;padding:16px 22px;border-radius:0 4px 4px 0;margin-top:26px}
.notes .nh{font-size:9px;font-weight:800;letter-spacing:1px;text-transform:uppercase;color:#002850;margin-bottom:8px}
.notes ul{list-style:none;display:flex;flex-direction:column;gap:6px}
.notes li{font-size:12px;color:#333;padding-left:18px;position:relative;line-height:1.7}
.notes li::before{content:"•";position:absolute;left:0;top:0;color:#A6DAD8;font-weight:800}
</style>
</head>
<body>
<div class="doc">
<!-- ════════════════════════════════════════════ -->
<!-- ═══════════════ MASTHEAD ══════════════ -->
<!-- ════════════════════════════════════════════ -->
<div class="mh">
<div class="kicker">UX Spec · Bulk Upload</div>
<h1>Uploading multiple documents in a single pass</h1>
<p>
Extends issue <strong>#294</strong> (new-document split-panel) with bulk uploads. When a user drops
N&nbsp;files, every metadata field applies once to all of them — only the <em>title</em> is per-file,
pre-filled from the filename and editable inline. A single save POST creates N documents.
</p>
<div class="byline">Prepared by Leonie Voss · 2026-04-24 · Draft 1 · References: #294, #305</div>
<div class="tag-row">
<span class="tag">feature</span>
<span class="tag mint">ui</span>
<span class="tag gray">a11y 320px+</span>
<span class="tag green">backend ready</span>
</div>
</div>
<!-- Goals -->
<div class="goals">
<h2>Design goals</h2>
<ul>
<li><strong>One-pass feel</strong>: drop → fill shared fields → save. No wizard, no per-file detour.</li>
<li><strong>Every field is shared except the title</strong>, which is always set (filename-derived).</li>
<li><strong>No mode switch</strong>: 1 file and N files use the same screen — more files reveal more chrome.</li>
<li><strong>Scales to 20+ files</strong> without the form losing scan-ability on mobile.</li>
<li><strong>Reuses the #294 split-panel layout</strong> (DocumentEditLayout) — minimum new surface.</li>
<li><strong>a11y-first</strong>: 44px targets, focus states, `aria-current` on active file, keyboard-navigable.</li>
</ul>
</div>
<!-- ════════════════════════════════════════════ -->
<!-- ═════════ CONCEPT A — STACK ═════════ -->
<!-- ════════════════════════════════════════════ -->
<section class="concept">
<div class="concept-header">
<div class="concept-num">A</div>
<div>
<div class="concept-label">Concept A</div>
<div class="concept-title">Flat Stack — shared header · file cards · sticky save</div>
<p class="concept-desc">
A single vertical flow: drop zone on top, then a <em>Gilt für alle</em> metadata card,
then stacked file cards (thumbnail · editable title · remove). No split panel, no tabs.
Scrolling down reveals all files; the save bar sticks to the bottom.
</p>
<div class="concept-best">
<span class="best-label">Best for</span>
<span class="best-text">Small-screen workflows. Seniors who prefer linear flows over tabs.</span>
</div>
<div class="concept-tradeoff">
Trade-off: no PDF preview until you click through to the document after save. Harder to verify
you grabbed the right files before committing.
</div>
</div>
</div>
<!-- mobile mockup -->
<div class="screen narrow">
<div class="chrome">
<div class="chrome-bar">
<div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-dot"></div>
<div class="chrome-url"></div>
<div class="viewport-hint">375 · mobile</div>
</div>
<div class="app-nav">
<div class="app-logo">Familienarchiv</div>
<div class="app-nav-r">
<div class="app-avatar">MR</div>
</div>
</div>
<div class="ca-top-bar">
<div class="ca-back">← Zurück</div>
<div class="ca-title">Neue Dokumente</div>
<div class="ca-count">5</div>
</div>
<div class="ca-body" style="height:500px">
<!-- drop zone -->
<div class="ca-drop">
<div class="ca-drop-icon"></div>
<div class="ca-drop-title">Weitere Dateien hinzufügen</div>
<div class="ca-drop-sub">PDF, JPEG, PNG, TIFF · max 50 MB</div>
</div>
<!-- shared card -->
<div class="ca-shared-card">
<div class="ca-shared-head">
<span class="ca-shared-badge">Gilt für alle 5</span>
<span class="ca-shared-title">Angaben</span>
</div>
<div class="ca-shared-grid">
<div class="ca-shared-field">
<span class="f-label">Absender</span>
<div class="f-input filled">Hans Müller</div>
</div>
<div class="ca-shared-field">
<span class="f-label">Empfänger</span>
<div class="f-input filled">Anna Schmidt</div>
</div>
<div class="ca-shared-field">
<span class="f-label">Datum</span>
<div class="f-input filled">1950-06</div>
</div>
<div class="ca-shared-field">
<span class="f-label">Ort</span>
<div class="f-input empty">Berlin</div>
</div>
<div class="ca-shared-field full">
<span class="f-label">Tags</span>
<div class="f-tags">
<span class="f-chip">Familie <span class="f-chip-rm">×</span></span>
<span class="f-chip">Krieg <span class="f-chip-rm">×</span></span>
</div>
</div>
</div>
</div>
<!-- files list -->
<div class="ca-files-head">
<div class="ca-files-title">5 Dateien · Titel bearbeiten</div>
</div>
<div class="ca-file active">
<div class="ca-thumb"><div class="tl h"></div><div class="tl"></div><div class="tl m"></div><div class="tl s"></div></div>
<div class="ca-file-body">
<div class="ca-file-title">Brief_1940_Hans</div>
<div class="ca-file-meta">Brief_1940_Hans.pdf · 2.4 MB</div>
</div>
<div class="ca-file-rm"></div>
</div>
<div class="ca-file">
<div class="ca-thumb"><div class="tl h"></div><div class="tl"></div><div class="tl"></div><div class="tl s"></div></div>
<div class="ca-file-body">
<div class="ca-file-title">Brief_1940_Anna</div>
<div class="ca-file-meta">Brief_1940_Anna.pdf · 1.8 MB</div>
</div>
<div class="ca-file-rm"></div>
</div>
<div class="ca-file">
<div class="ca-thumb"><div class="tl h"></div><div class="tl m"></div><div class="tl"></div><div class="tl"></div></div>
<div class="ca-file-body">
<div class="ca-file-title">Brief_1941_Clara</div>
<div class="ca-file-meta">Brief_1941_Clara.pdf · 890 kB</div>
</div>
<div class="ca-file-rm"></div>
</div>
<div class="ca-file">
<div class="ca-thumb"><div class="tl h"></div><div class="tl"></div><div class="tl s"></div><div class="tl m"></div></div>
<div class="ca-file-body">
<div class="ca-file-title placeholder">Postkarte_Venedig</div>
<div class="ca-file-meta">Postkarte_Venedig.jpg · 1.1 MB</div>
</div>
<div class="ca-file-rm"></div>
</div>
</div>
<div class="action-bar">
<div class="btn-skip">Alle verwerfen</div>
<div class="btn-spacer"></div>
<div class="btn-outline">Als Platzhalter</div>
<div class="btn-primary">5 speichern →</div>
</div>
</div>
</div>
</section>
<!-- ════════════════════════════════════════════ -->
<!-- ═══ CONCEPT B — SPLIT-PANEL + SWITCHER ══ -->
<!-- ════════════════════════════════════════════ -->
<section class="concept">
<div class="concept-header">
<div class="concept-num">B</div>
<div>
<div class="concept-label">Concept B · RECOMMENDED</div>
<div class="concept-title">Split-Panel with File Switcher</div>
<p class="concept-desc">
Reuses the <em>DocumentEditLayout</em> from issue #294 and adds a horizontal file-switcher strip
under the PDF preview. Right column splits into two cards: <em>Gilt nur für diese Datei</em>
(title only, mint accent) and <em>Gilt für alle N Dokumente</em> (everything else).
When N=1 the switcher disappears and the screen is byte-identical to #294.
</p>
<div class="concept-best">
<span class="best-label">Best for</span>
<span class="best-text">The project's primary use case. Desktop + tablet, matches #294 DNA.</span>
</div>
<div class="concept-tradeoff">
Trade-off: on mobile the split has to collapse into tabs ("Vorschau / Angaben"). We reuse the
same responsive pattern that DocumentEditLayout already ships with.
</div>
</div>
</div>
<!-- desktop mockup -->
<div class="screen">
<div class="chrome">
<div class="chrome-bar">
<div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-dot"></div>
<div class="chrome-url"></div>
<div class="viewport-hint">1280 · desktop</div>
</div>
<div class="app-nav">
<div class="app-logo">Familienarchiv</div>
<div class="app-link on">Dokumente</div>
<div class="app-link">Personen</div>
<div class="app-link">Briefwechsel</div>
<div class="app-link">Chronik</div>
<div class="app-nav-r">
<div class="app-avatar">MR</div>
</div>
</div>
<div class="cb-top-bar">
<div class="cb-back">← Dokumente</div>
<div class="cb-title">Neue Dokumente</div>
<div class="cb-count">5 werden erstellt</div>
<div class="cb-discard">Alle verwerfen</div>
</div>
<div class="cb-split">
<!-- PDF side -->
<div class="cb-pdf">
<div class="cb-pdf-toolbar">
<div class="cb-pdf-btn"></div>
<div class="cb-pdf-btn"></div>
<div class="cb-pdf-btn">+</div>
<div class="cb-pdf-btn"></div>
<div class="cb-pdf-page">Seite 1 / 2 · Datei 1 von 5</div>
</div>
<div class="cb-pdf-view">
<div class="cb-paper">
<div class="pl h"></div><div class="pl h s"></div><div class="pl sp"></div>
<div class="pl"></div><div class="pl m"></div><div class="pl"></div>
<div class="pl s"></div><div class="pl sp"></div>
<div class="pl"></div><div class="pl m"></div><div class="pl"></div>
<div class="pl"></div><div class="pl s"></div><div class="pl sp"></div>
<div class="pl"></div><div class="pl m"></div><div class="pl s"></div>
</div>
</div>
<!-- file switcher -->
<div class="cb-filebar">
<div class="cb-fb-arrow"></div>
<div class="cb-fb-track">
<div class="cb-fb-item on"><span class="cb-fb-num">1</span> Brief_1940_Hans.pdf</div>
<div class="cb-fb-item"><span class="cb-fb-num">2</span> Brief_1940_Anna.pdf</div>
<div class="cb-fb-item"><span class="cb-fb-num">3</span> Brief_1941_Clara.pdf</div>
<div class="cb-fb-item"><span class="cb-fb-num">4</span> Postkarte_Venedig.jpg</div>
<div class="cb-fb-item"><span class="cb-fb-num">5</span> Urkunde_1942.pdf</div>
</div>
<div class="cb-fb-arrow"></div>
</div>
</div>
<!-- Form side -->
<div class="cb-form">
<div class="cb-form-scroll">
<!-- PER-FILE card -->
<div class="cb-only-card">
<div class="cb-only-head">
<span class="cb-only-badge">Nur diese Datei</span>
<span class="cb-only-subtitle">1 / 5 · Brief_1940_Hans.pdf</span>
</div>
<div class="cb-row full">
<div class="cb-field">
<span class="f-label">Titel <span class="f-req">*</span></span>
<div class="f-input filled tall">Brief an Anna, 1940</div>
</div>
</div>
</div>
<!-- SHARED card -->
<div class="cb-shared-card">
<div class="cb-shared-head">
<span class="cb-shared-badge">Gilt für alle 5</span>
<span class="cb-shared-subtitle">Gemeinsame Angaben</span>
</div>
<div class="cb-row">
<div class="cb-field">
<span class="f-label">Absender</span>
<div class="f-input filled">Hans Müller</div>
</div>
<div class="cb-field">
<span class="f-label">Empfänger</span>
<div class="f-input filled">Anna Schmidt</div>
</div>
</div>
<div class="cb-row">
<div class="cb-field">
<span class="f-label">Datum</span>
<div class="f-input filled">15.06.1950</div>
</div>
<div class="cb-field">
<span class="f-label">Ort</span>
<div class="f-input empty">z.B. Berlin</div>
</div>
</div>
<div class="cb-row full">
<div class="cb-field">
<span class="f-label">Tags</span>
<div class="f-tags">
<span class="f-chip">Familie <span class="f-chip-rm">×</span></span>
<span class="f-chip">Krieg <span class="f-chip-rm">×</span></span>
<span class="f-chip">Briefwechsel <span class="f-chip-rm">×</span></span>
</div>
</div>
</div>
<div class="cb-row">
<div class="cb-field">
<span class="f-label">Archivbox</span>
<div class="f-input empty">z.B. B-12</div>
</div>
<div class="cb-field">
<span class="f-label">Mappe</span>
<div class="f-input empty">z.B. M-3</div>
</div>
</div>
</div>
</div>
<div class="action-bar">
<div class="btn-skip">Alle verwerfen</div>
<div class="btn-spacer"></div>
<div class="btn-outline">Als Platzhalter</div>
<div class="btn-primary green">5 speichern →</div>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- ════════════════════════════════════════════ -->
<!-- ══ CONCEPT C — PROGRESSIVE ACCORDION ══ -->
<!-- ════════════════════════════════════════════ -->
<section class="concept">
<div class="concept-header">
<div class="concept-num">C</div>
<div>
<div class="concept-label">Concept C</div>
<div class="concept-title">Progressive Accordion — shared sticky header · file cards expand inline</div>
<p class="concept-desc">
Shared metadata sticks at the top of the page. Below, each file is a collapsed card; clicking
a card expands it to show the PDF preview + title field inline. Only one card is expanded at a
time. Scales well to 20+ files — the list stays readable, you only look at the PDFs you want
to verify.
</p>
<div class="concept-best">
<span class="best-label">Best for</span>
<span class="best-text">Large batches (10+ files) where you want to spot-check a few.</span>
</div>
<div class="concept-tradeoff">
Trade-off: two different visual languages — cards collapsed vs. cards expanded with PDF. New
pattern for the project; costs familiarity.
</div>
</div>
</div>
<div class="screen">
<div class="chrome">
<div class="chrome-bar">
<div class="chrome-dot"></div><div class="chrome-dot"></div><div class="chrome-dot"></div>
<div class="chrome-url"></div>
<div class="viewport-hint">1280 · desktop</div>
</div>
<div class="app-nav">
<div class="app-logo">Familienarchiv</div>
<div class="app-link on">Dokumente</div>
<div class="app-link">Personen</div>
<div class="app-nav-r"><div class="app-avatar">MR</div></div>
</div>
<div class="cc-top-bar">
<div class="ca-back">← Zurück</div>
<div class="ca-title">Neue Dokumente</div>
<div class="ca-count">5</div>
</div>
<div class="cc-body">
<!-- sticky shared card -->
<div class="cc-shared">
<div class="cc-shared-head">
<span class="cc-shared-badge">Gilt für alle 5</span>
<span class="cc-shared-title">Gemeinsame Angaben</span>
</div>
<div class="cc-grid">
<div class="cb-field"><span class="f-label">Absender</span><div class="f-input filled">Hans Müller</div></div>
<div class="cb-field"><span class="f-label">Empfänger</span><div class="f-input filled">Anna Schmidt</div></div>
<div class="cb-field"><span class="f-label">Datum</span><div class="f-input filled">15.06.1950</div></div>
<div class="cb-field span2"><span class="f-label">Tags</span><div class="f-tags"><span class="f-chip">Familie <span class="f-chip-rm">×</span></span><span class="f-chip">Krieg <span class="f-chip-rm">×</span></span></div></div>
<div class="cb-field"><span class="f-label">Ort</span><div class="f-input empty">z.B. Berlin</div></div>
</div>
</div>
<div class="cc-files-label">5 Dateien</div>
<!-- collapsed card -->
<div class="cc-file">
<div class="cc-file-head">
<div class="cc-caret"></div>
<div class="cc-file-thumb"><div class="tl"></div><div class="tl"></div><div class="tl"></div></div>
<div class="cc-file-body">
<div class="cc-file-titlerow">
<div class="cc-file-title">Brief an Anna, 1940</div>
</div>
<div class="cc-file-meta">Brief_1940_Hans.pdf · 2.4 MB</div>
</div>
<div class="cc-file-rm"></div>
</div>
</div>
<!-- expanded card -->
<div class="cc-file open">
<div class="cc-file-head open">
<div class="cc-caret" style="color:#002850"></div>
<div class="cc-file-thumb"><div class="tl"></div><div class="tl"></div><div class="tl"></div></div>
<div class="cc-file-body">
<div class="cc-file-titlerow">
<div class="cc-file-title">Brief von Anna, Antwort</div>
</div>
<div class="cc-file-meta">Brief_1940_Anna.pdf · 1.8 MB</div>
</div>
<div class="cc-file-rm"></div>
</div>
<div class="cc-file-open">
<div class="cc-preview">
<div class="cc-preview-paper">
<div class="pl h"></div><div class="pl h s"></div><div class="pl sp"></div>
<div class="pl"></div><div class="pl m"></div><div class="pl s"></div>
<div class="pl sp"></div>
<div class="pl"></div><div class="pl"></div><div class="pl m"></div>
</div>
</div>
<div class="cc-file-form">
<div class="cb-only-head">
<span class="cb-only-badge">Nur diese Datei</span>
<span class="cb-only-subtitle">2 / 5</span>
</div>
<div class="cb-field">
<span class="f-label">Titel <span class="f-req">*</span></span>
<div class="f-input filled tall">Brief von Anna, Antwort</div>
</div>
</div>
</div>
</div>
<!-- more collapsed -->
<div class="cc-file">
<div class="cc-file-head">
<div class="cc-caret"></div>
<div class="cc-file-thumb"><div class="tl"></div><div class="tl"></div><div class="tl"></div></div>
<div class="cc-file-body">
<div class="cc-file-titlerow">
<div class="cc-file-title placeholder">Brief_1941_Clara</div>
</div>
<div class="cc-file-meta">Brief_1941_Clara.pdf · 890 kB</div>
</div>
<div class="cc-file-rm"></div>
</div>
</div>
<div class="cc-file">
<div class="cc-file-head">
<div class="cc-caret"></div>
<div class="cc-file-thumb"><div class="tl"></div><div class="tl"></div><div class="tl"></div></div>
<div class="cc-file-body">
<div class="cc-file-titlerow">
<div class="cc-file-title placeholder">Postkarte_Venedig</div>
</div>
<div class="cc-file-meta">Postkarte_Venedig.jpg · 1.1 MB</div>
</div>
<div class="cc-file-rm"></div>
</div>
</div>
<div class="cc-file">
<div class="cc-file-head">
<div class="cc-caret"></div>
<div class="cc-file-thumb"><div class="tl"></div><div class="tl"></div><div class="tl"></div></div>
<div class="cc-file-body">
<div class="cc-file-titlerow">
<div class="cc-file-title placeholder">Urkunde_1942</div>
</div>
<div class="cc-file-meta">Urkunde_1942.pdf · 3.1 MB</div>
</div>
<div class="cc-file-rm"></div>
</div>
</div>
</div>
<div class="action-bar">
<div class="btn-skip">Alle verwerfen</div>
<div class="btn-spacer"></div>
<div class="btn-outline">Als Platzhalter</div>
<div class="btn-primary green">5 speichern →</div>
</div>
</div>
</div>
</section>
<!-- ════════════════════════════════════════════ -->
<!-- ══════════ DECISION MATRIX ════════════ -->
<!-- ════════════════════════════════════════════ -->
<div class="decision">
<h2>Decision matrix</h2>
<p class="lead">
All three concepts meet the core requirement (shared metadata + per-file title + one save).
Graded against what matters for the senior audience, the responsive constraint, and the #294
architectural commitment.
</p>
<table class="dm">
<thead>
<tr>
<th>Dimension</th>
<th>A · Stack</th>
<th>B · Split-Panel</th>
<th>C · Accordion</th>
</tr>
</thead>
<tbody>
<tr>
<td>Reuses #294 layout</td>
<td class="score bad"></td>
<td class="score ok"></td>
<td class="score bad"></td>
</tr>
<tr>
<td>Single-file mode unchanged</td>
<td class="score mid">rewrite</td>
<td class="score ok">identical</td>
<td class="score bad">different</td>
</tr>
<tr>
<td>PDF visible before save</td>
<td class="score bad">no</td>
<td class="score ok">always</td>
<td class="score mid">one at a time</td>
</tr>
<tr>
<td>Works at 320px</td>
<td class="score ok">native</td>
<td class="score mid">via tab collapse</td>
<td class="score ok">native</td>
</tr>
<tr>
<td>Scales to 20 files</td>
<td class="score mid">long scroll</td>
<td class="score ok">switcher scrolls</td>
<td class="score ok">collapsed list</td>
</tr>
<tr>
<td>New Svelte components</td>
<td class="score bad">3 new</td>
<td class="score ok">1 new (switcher)</td>
<td class="score bad">4 new</td>
</tr>
<tr>
<td>Familiar pattern</td>
<td class="score ok">yes</td>
<td class="score ok">yes (post-#294)</td>
<td class="score mid">new to app</td>
</tr>
</tbody>
</table>
</div>
<!-- ════════════════════════════════════════════ -->
<!-- ══════════ RECOMMENDATION ════════════ -->
<!-- ════════════════════════════════════════════ -->
<div class="reco">
<div class="kicker">Recommendation</div>
<h2>Ship Concept B</h2>
<p class="why">
Concept&nbsp;B treats bulk upload as a <em>polymorphic state</em> of the existing single-document
layout rather than a separate screen. A user who drops one file gets exactly the #294 experience.
A user who drops five gets the same screen plus a horizontal file-switcher and a two-card split
(<em>Nur diese Datei</em> vs. <em>Gilt für alle</em>). Nothing about the single-file flow changes.
</p>
<ul>
<li>Keeps the mental model: "one form, one save" regardless of file count.</li>
<li>PDF preview is persistent — you can spot-check each scan before committing.</li>
<li>The per-file title is visually promoted with a mint border so it reads as the one thing that differs per file.</li>
<li>Reuses DocumentEditLayout: the delta is ~1 new component (<code>FileSwitcherStrip</code>) + two cards in the form.</li>
<li>Single-file mode is byte-identical to #294 — no regression risk for existing users.</li>
<li>Backend is already ready (<code>POST /api/documents/quick-upload</code> accepts N files in one multipart).</li>
</ul>
</div>
<!-- ════════════════════════════════════════════ -->
<!-- ══════════ IMPL-REF · CONCEPT B ═══════ -->
<!-- ════════════════════════════════════════════ -->
<div class="impl">
<h2>Implementation reference — Concept B</h2>
<h3>Top bar (when N > 1)</h3>
<table class="impl-table">
<tr><th>Element</th><th>Tailwind</th><th>Px / value</th><th>Note</th></tr>
<tr>
<td>Count pill "N werden erstellt"</td>
<td><code>bg-accent text-primary rounded-full px-3 py-1 text-sm font-bold</code></td>
<td class="px">14px · 700</td>
<td class="note">brand-mint on brand-navy</td>
</tr>
<tr>
<td>"Alle verwerfen" link</td>
<td><code>ml-auto text-sm font-bold text-red-600 hover:text-red-800 focus-visible:outline-2 focus-visible:outline-red-600</code></td>
<td class="px">14px / 44px target</td>
<td class="note">confirm dialog before wiping</td>
</tr>
</table>
<h3 class="ix">FileSwitcherStrip (new component)</h3>
<table class="impl-table">
<tr><th>Element</th><th>Tailwind</th><th>Px / value</th><th>Note</th></tr>
<tr>
<td>Strip container</td>
<td><code>flex items-center gap-1 bg-ink/95 px-2 py-2 border-t border-ink/80</code></td>
<td class="px">height 48px</td>
<td class="note">under the PDF toolbar, on the dark panel</td>
</tr>
<tr>
<td>Arrow buttons</td>
<td><code>h-10 w-10 rounded-sm bg-white/8 text-surface/60 hover:bg-white/15 focus-visible:outline-2</code></td>
<td class="px">40×40 (44 w/padding)</td>
<td class="note"><code>aria-label="Vorherige Datei"</code> / "Nächste Datei"</td>
</tr>
<tr>
<td>File chip (inactive)</td>
<td><code>px-3 py-2 rounded-sm bg-white/6 text-sm font-bold text-surface/55 whitespace-nowrap hover:bg-white/12</code></td>
<td class="px">14px / h 40px</td>
<td class="note">horizontal scroll container uses <code>snap-x snap-mandatory</code></td>
</tr>
<tr>
<td>File chip (active)</td>
<td><code>... bg-accent text-primary</code> + <code>aria-current="true"</code></td>
<td class="px">14px / h 40px</td>
<td class="note">mint pill, primary text — 7.2:1 contrast passes AAA</td>
</tr>
<tr>
<td>Chip number prefix</td>
<td><code>bg-primary/25 rounded-sm px-1 mr-2 text-xs font-extrabold</code></td>
<td class="px">12px / 800</td>
<td class="note">"1", "2", … — for quick scanning</td>
</tr>
</table>
<h3 class="ix">"Nur diese Datei" card (per-file scope)</h3>
<table class="impl-table">
<tr><th>Element</th><th>Tailwind</th><th>Px / value</th><th>Note</th></tr>
<tr>
<td>Card container</td>
<td><code>bg-accent/20 border border-accent rounded-sm p-4 mb-4</code></td>
<td class="px">padding 16px</td>
<td class="note">mint tint signals "different per file"</td>
</tr>
<tr>
<td>Scope badge</td>
<td><code>bg-primary/90 text-accent rounded-sm px-2 py-1 text-xs font-extrabold uppercase tracking-wide</code></td>
<td class="px">12px · 800</td>
<td class="note">Paraglide key: <code>bulk_only_this_file</code></td>
</tr>
<tr>
<td>Title input</td>
<td><code>h-11 text-base font-semibold text-ink bg-white border border-line rounded-sm px-3 focus-visible:border-ink focus-visible:ring-2 focus-visible:ring-ink/20</code></td>
<td class="px">44px min-height · 16px</td>
<td class="note">pre-filled from filename <em>without extension</em></td>
</tr>
</table>
<h3 class="ix">"Gilt für alle" card (shared scope)</h3>
<table class="impl-table">
<tr><th>Element</th><th>Tailwind</th><th>Px / value</th><th>Note</th></tr>
<tr>
<td>Card container</td>
<td><code>bg-surface border border-line rounded-sm p-4 mb-3</code></td>
<td class="px">padding 16px</td>
<td class="note">neutral (no accent tint)</td>
</tr>
<tr>
<td>Scope badge</td>
<td><code>bg-accent text-primary rounded-sm px-2 py-1 text-xs font-extrabold uppercase tracking-wide</code></td>
<td class="px">12px · 800</td>
<td class="note">Paraglide: <code>bulk_shared_count</code> ("Gilt für alle {count}")</td>
</tr>
<tr>
<td>Field grid</td>
<td><code>grid grid-cols-1 md:grid-cols-2 gap-3</code></td>
<td class="px">12px gap</td>
<td class="note">single column at 320px, two at ≥ 768px</td>
</tr>
</table>
<h3 class="ix">Save bar</h3>
<table class="impl-table">
<tr><th>Element</th><th>Tailwind</th><th>Px / value</th><th>Note</th></tr>
<tr>
<td>Primary save button</td>
<td><code>h-11 px-5 bg-green-700 hover:bg-green-800 text-white font-extrabold rounded-sm text-sm focus-visible:ring-2 focus-visible:ring-green-900</code></td>
<td class="px">44px min · 14px</td>
<td class="note">label <code>{count} speichern →</code> (plural-aware Paraglide)</td>
</tr>
<tr>
<td>"Als Platzhalter" (outline)</td>
<td><code>h-11 px-4 border border-line bg-white text-ink-3 font-bold rounded-sm text-sm</code></td>
<td class="px">44px</td>
<td class="note">posts with <code>metadataComplete=false</code> for all</td>
</tr>
</table>
<h3 class="ix">Responsive collapse (≤ 767px)</h3>
<table class="impl-table">
<tr><th>Element</th><th>Tailwind</th><th>Px / value</th><th>Note</th></tr>
<tr>
<td>Panel mode switch</td>
<td>reuses DocumentEditLayout's existing tab collapse — "Vorschau / Angaben" tabs</td>
<td class="px">tab height 48px</td>
<td class="note">already shipped with #294</td>
</tr>
<tr>
<td>File switcher stays on "Vorschau" tab</td>
<td><code>snap-x snap-mandatory overflow-x-auto</code></td>
<td class="px">h 44px</td>
<td class="note">horizontal swipe; arrow buttons removed at mobile</td>
</tr>
</table>
<div class="notes">
<div class="nh">Interactions + behaviour</div>
<ul>
<li><strong>Drop a file after the initial batch</strong>: append to the end of the list and switch focus to the newly added file. No modal, no confirmation.</li>
<li><strong>Remove a file</strong> (X on the chip) → confirm only if it's the currently-previewed one; otherwise silent. When count drops to 1 the switcher strip animates away (200ms); when it drops to 0 we redirect back to the drop-zone state.</li>
<li><strong>Title auto-fill</strong>: <code>filename.replace(/\.(pdf|jpe?g|png|tiff?)$/i, '').replace(/[_-]+/g, ' ').trim()</code>. Marks the title input as <code>suggested</code> until the user edits it (mint left border, same treatment as #294's filename-derived fields).</li>
<li><strong>Title field visibility</strong>: always rendered (never collapsed) even in single-file mode, so there's zero layout jump when N changes from 1 to 2.</li>
<li><strong>Save flow</strong>: single POST to <code>/api/documents/quick-upload</code> with N files + JSON metadata object containing shared fields + titles array. Backend maps title[i] to files[i] by index. Response splits into <code>created[] / updated[] / errors[]</code> — show a summary toast + inline error markers per file for the <code>errors[]</code> list.</li>
<li><strong>Keyboard navigation</strong>: <kbd></kbd>/<kbd></kbd> on the switcher strip moves file focus; <kbd>Tab</kbd> cycles through form fields inside whichever card is active; <kbd>Esc</kbd> on the discard button opens the confirm dialog.</li>
<li><strong>Focus management on file switch</strong>: when the user clicks a different file, the title input of the new file receives focus automatically (so the main editable field is always reachable).</li>
<li><strong>Progress indicator during save</strong>: replace the save button with a determinate progress bar showing "Lade Datei 3 von 5…" for batches that take > 500ms.</li>
</ul>
</div>
<div class="notes" style="margin-top:14px;border-left-color:#C0392B">
<div class="nh" style="color:#C0392B">Edge cases + a11y</div>
<ul>
<li><strong>Duplicate filenames in the batch</strong>: accept, but show a warning icon next to both — backend will create both with unique IDs.</li>
<li><strong>Mixed content types</strong>: PDF + image in the same batch is fine; the preview panel renders whichever the active file is (DocumentEditLayout already handles both).</li>
<li><strong>Large batches (> 20 files)</strong>: the switcher strip becomes scrollable; consider a "Jump to file…" combobox at > 30 files (out of scope for v1).</li>
<li><strong>Upload failure per file</strong>: mark the chip red (<code>bg-red-600/20 text-red-800 border border-red-600</code>), show inline error in the chip's tooltip, don't block the rest of the batch from retrying.</li>
<li><strong>Screen reader announcement</strong>: when file count changes, fire a polite live region announce — "5 Dateien bereit zum Speichern" via <code>role="status" aria-live="polite"</code>.</li>
<li><strong>Colour-alone warning</strong>: active file chip uses color + <code>aria-current="true"</code> + a ▸ caret prefix so it's distinguishable for color-blind users.</li>
</ul>
</div>
</div>
</div>
</body>
</html>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,65 @@
import AxeBuilder from '@axe-core/playwright';
import { test, expect } from '@playwright/test';
import {
seedBilateralPair,
cleanupBilateralPair,
type BilateralPair
} from './fixtures/bilateral-correspondence';
// Accessibility coverage for the briefwechsel thumbnail-row layout. Seeds
// two persons + a bilateral document via the shared fixture so the page
// reaches the results state (not the hero), then runs axe-core
// (wcag2a + wcag2aa) across three viewports and two color schemes.
const VIEWPORTS = [
{ name: 'mobile', width: 375, height: 812 },
{ name: 'tablet', width: 768, height: 1024 },
{ name: 'desktop', width: 1280, height: 800 }
] as const;
const THEMES = ['light', 'dark'] as const;
let pair: BilateralPair;
test.describe('Accessibility — /briefwechsel row layout', () => {
test.beforeAll(async ({ request }) => {
pair = await seedBilateralPair(request, 'A11y');
});
test.afterAll(async ({ request }) => {
await cleanupBilateralPair(request, pair);
});
for (const vp of VIEWPORTS) {
for (const theme of THEMES) {
test(`${vp.name} / ${theme} has no wcag2a/wcag2aa violations`, async ({ page }) => {
await page.setViewportSize({ width: vp.width, height: vp.height });
await page.emulateMedia({ colorScheme: theme });
await page.goto(
`/briefwechsel?senderId=${encodeURIComponent(pair.senderId)}&receiverId=${encodeURIComponent(pair.receiverId)}`
);
await page.waitForSelector('[data-hydrated]');
// Assert we actually reached the row layout, not the hero — otherwise
// the axe sweep silently scans the wrong DOM.
await expect(page.getByTestId('conv-person-bar')).toBeVisible();
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa'])
.include('main')
.analyze();
if (results.violations.length > 0) {
const summary = results.violations
.map((v) => `[${v.impact}] ${v.id}: ${v.description} (${v.nodes.length} node(s))`)
.join('\n');
console.log(
`\nAccessibility violations on briefwechsel (${vp.name}/${theme}):\n${summary}`
);
}
expect(results.violations).toEqual([]);
});
}
}
});

View File

@@ -0,0 +1,79 @@
import { test, expect } from '@playwright/test';
import {
seedBilateralPair,
cleanupBilateralPair,
type BilateralPair
} from './fixtures/bilateral-correspondence';
// Visual + structural coverage for the new briefwechsel row layout.
//
// Seeds a bilateral correspondence pair via the shared fixture so the page
// reaches the row state. The structural test asserts that a
// ConversationThumbnail tile AND the DistributionBar render — regressions
// that silently drop to the hero or break the {#each} wiring fail here.
//
// Snapshot assertions are gated on the VISUAL env flag because they need
// pre-captured baselines (see `playwright test --update-snapshots` to
// regenerate after intentional UI changes). CI can opt in via VISUAL=1.
const VISUAL = process.env.VISUAL === '1';
let pair: BilateralPair;
test.describe('Briefwechsel — thumbnail-row layout', () => {
test.beforeAll(async ({ request }) => {
pair = await seedBilateralPair(request, 'Visual');
});
test.afterAll(async ({ request }) => {
await cleanupBilateralPair(request, pair);
});
async function openBilateral(page: import('@playwright/test').Page) {
await page.goto(
`/briefwechsel?senderId=${encodeURIComponent(pair.senderId)}&receiverId=${encodeURIComponent(pair.receiverId)}`
);
await page.waitForSelector('[data-hydrated]');
// Parity with the a11y spec: fail loudly if we ever end up on the hero
// instead of the row layout.
await expect(page.getByTestId('conv-person-bar')).toBeVisible();
}
test('renders a ConversationThumbnail tile and the DistributionBar', async ({ page }) => {
await openBilateral(page);
// Tile appears for the seeded document
await expect(page.locator('[data-testid="conv-thumb-tile"]').first()).toBeVisible();
// DistributionBar is present (role=img with a descriptive aria-label)
const bar = page.locator('[role="img"]');
await expect(bar).toBeVisible();
const label = (await bar.getAttribute('aria-label')) ?? '';
expect(label.length).toBeGreaterThan(0);
});
// Visual regression — one snapshot per (viewport × theme). Tolerance stays
// generous (maxDiffPixels: 100) so antialiasing jitter doesn't flip them on
// unrelated runs; genuine layout changes are still caught because the
// thumbnail tile and distribution bar dominate the frame.
test.describe('snapshots', () => {
test.skip(!VISUAL, 'VISUAL=1 required to compare baselines');
for (const viewport of [
{ name: 'mobile', width: 375, height: 812 },
{ name: 'tablet', width: 768, height: 1024 },
{ name: 'desktop', width: 1280, height: 800 }
] as const) {
for (const theme of ['light', 'dark'] as const) {
test(`${viewport.name} / ${theme}`, async ({ page }) => {
await page.setViewportSize({ width: viewport.width, height: viewport.height });
await page.emulateMedia({ colorScheme: theme });
await openBilateral(page);
await expect(page).toHaveScreenshot(`briefwechsel-${viewport.name}-${theme}.png`, {
maxDiffPixels: 100,
fullPage: true
});
});
}
}
});
});

View File

@@ -0,0 +1,75 @@
import { test, expect } from '@playwright/test';
/**
* E2E coverage for the bulk metadata edit feature (issue #225).
*
* Assumptions:
* - Auth setup has run as the admin user (WRITE_ALL).
* - The backend exposes /api/documents/{bulk,batch-metadata,ids}.
* - At least two documents exist in the search list at /documents.
*/
test.describe('Bulk metadata edit', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/documents');
await page.waitForSelector('[data-hydrated]');
});
test('checking two documents shows the sticky selection bar with the count', async ({ page }) => {
const checkboxes = page.locator('[data-testid="bulk-select-checkbox"] input[type="checkbox"]');
await expect(checkboxes.first()).toBeVisible();
await checkboxes.nth(0).check();
await checkboxes.nth(1).check();
const bar = page.getByTestId('bulk-selection-bar');
await expect(bar).toBeVisible();
await expect(page.getByTestId('bulk-selection-count')).toContainText('2');
});
test('Alles aufheben hides the bar', async ({ page }) => {
const checkboxes = page.locator('[data-testid="bulk-select-checkbox"] input[type="checkbox"]');
await checkboxes.nth(0).check();
await expect(page.getByTestId('bulk-selection-bar')).toBeVisible();
await page.getByTestId('bulk-clear-all').click();
await expect(page.getByTestId('bulk-selection-bar')).not.toBeVisible();
});
test('Massenbearbeitung navigates to bulk-edit with the selected documents', async ({ page }) => {
const checkboxes = page.locator('[data-testid="bulk-select-checkbox"] input[type="checkbox"]');
await checkboxes.nth(0).check();
await checkboxes.nth(1).check();
await page.getByTestId('bulk-edit-open').click();
await page.waitForURL('**/documents/bulk-edit');
// Onboarding callout is the surest indicator the edit-mode layout rendered.
await expect(page.getByTestId('bulk-edit-callout')).toBeVisible();
});
test('navigating to /documents/bulk-edit with no selection redirects back to /documents', async ({
page
}) => {
// Navigate directly without checking anything first.
await page.goto('/documents/bulk-edit');
await page.waitForURL('**/documents');
expect(page.url()).toMatch(/\/documents(\?|$)/);
});
test('the same selection bar drives the /enrich page', async ({ page }) => {
await page.goto('/enrich');
await page.waitForSelector('[data-hydrated]');
// /enrich may legitimately be empty if every doc has metadata. In that
// case there's nothing to bulk-select; skip.
const checkboxes = page.locator('[data-testid="bulk-select-checkbox"] input[type="checkbox"]');
const count = await checkboxes.count();
test.skip(count === 0, 'No incomplete documents available on /enrich');
await checkboxes.first().check();
await expect(page.getByTestId('bulk-selection-bar')).toBeVisible();
await expect(page.getByTestId('bulk-selection-count')).toContainText('1');
await page.getByTestId('bulk-clear-all').click();
await expect(page.getByTestId('bulk-selection-bar')).not.toBeVisible();
});
});

View File

@@ -0,0 +1,62 @@
import type { APIRequestContext } from '@playwright/test';
/**
* Test fixture for the briefwechsel row layout.
*
* Creates two persons and one document with sender/receiver between them so
* that `/briefwechsel?senderId=X&receiverId=Y` navigates straight to the row
* state (not the hero). Each seed uses a `Date.now()`-suffixed last name so
* parallel runs and reruns never collide.
*
* The backend does not expose a person-delete endpoint, so only the document
* is cleaned up in {@link cleanupBilateralPair}. The two timestamped persons
* remain in the DB — acceptable for the test environment, and the unique
* suffix means they cannot conflict with later runs.
*/
export interface BilateralPair {
senderId: string;
receiverId: string;
documentId: string;
}
export async function seedBilateralPair(
request: APIRequestContext,
prefix: string
): Promise<BilateralPair> {
const timestamp = Date.now();
const senderRes = await request.post('/api/persons', {
data: { firstName: prefix, lastName: `Sender-${timestamp}` }
});
if (!senderRes.ok()) throw new Error(`Create sender failed: ${senderRes.status()}`);
const senderId = (await senderRes.json()).id as string;
const receiverRes = await request.post('/api/persons', {
data: { firstName: prefix, lastName: `Receiver-${timestamp}` }
});
if (!receiverRes.ok()) throw new Error(`Create receiver failed: ${receiverRes.status()}`);
const receiverId = (await receiverRes.json()).id as string;
const docRes = await request.post('/api/documents', {
multipart: {
title: `${prefix} Brief`,
documentDate: '1950-06-15',
senderId,
receiverIds: receiverId
}
});
if (!docRes.ok()) throw new Error(`Create document failed: ${docRes.status()}`);
const documentId = (await docRes.json()).id as string;
return { senderId, receiverId, documentId };
}
export async function cleanupBilateralPair(
request: APIRequestContext,
pair: BilateralPair
): Promise<void> {
// Only the document is purged — the backend has no person-delete endpoint
// and the timestamped last names make orphaned person rows safe to leave.
await request.delete(`/api/documents/${pair.documentId}`);
}

View File

@@ -0,0 +1,36 @@
import { test, expect } from '@playwright/test';
import { createEmptyDocument } from './helpers/upload-empty-document.js';
test.describe('Help chip — Read/Edit panel header', () => {
let docId: string;
test.beforeAll(async ({ request }) => {
docId = await createEmptyDocument(request);
});
test.afterAll(async ({ request }) => {
await request.delete(`/api/documents/${docId}`);
});
test('opens popover on click, closes on Esc, returns focus to chip', async ({ page }) => {
await page.goto(`/documents/${docId}`);
await page.getByRole('button', { name: 'Transkribieren' }).click();
// Use the accessible label of the HelpPopover trigger (transcription_mode_help_label)
const helpBtn = page.getByRole('button', { name: 'Lese- und Bearbeitungsmodus' });
await expect(helpBtn).toBeVisible({ timeout: 5000 });
await helpBtn.click();
// Popover should open (role="region", not tooltip — click-triggered panels are regions)
await expect(page.getByRole('region', { name: 'Lese- und Bearbeitungsmodus' })).toBeVisible();
// Press Esc
await page.keyboard.press('Escape');
await expect(
page.getByRole('region', { name: 'Lese- und Bearbeitungsmodus' })
).not.toBeVisible();
// Focus should have returned to the chip
await expect(helpBtn).toBeFocused();
});
});

View File

@@ -0,0 +1,30 @@
import type { APIRequestContext } from '@playwright/test';
import path from 'path';
import { fileURLToPath } from 'url';
import fs from 'fs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PDF_FIXTURE = path.resolve(__dirname, '../fixtures/minimal.pdf');
export async function createEmptyDocument(request: APIRequestContext): Promise<string> {
const createRes = await request.post('/api/documents', {
multipart: { title: 'E2E Transcribe Coach Test' }
});
if (!createRes.ok()) throw new Error(`Create document failed: ${createRes.status()}`);
const doc = await createRes.json();
const docId = doc.id as string;
const uploadRes = await request.put(`/api/documents/${docId}`, {
multipart: {
title: doc.title,
file: {
name: 'minimal.pdf',
mimeType: 'application/pdf',
buffer: fs.readFileSync(PDF_FIXTURE)
}
}
});
if (!uploadRes.ok()) throw new Error(`Upload PDF failed: ${uploadRes.status()}`);
return docId;
}

View File

@@ -0,0 +1,74 @@
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
function buildAxe(page: Parameters<typeof AxeBuilder>[0]['page']) {
return new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']);
}
test.describe('Richtlinien page — content', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/hilfe/transkription');
});
test('renders h1 title, intro, five rules, four chips, closing card', async ({ page }) => {
await expect(
page.getByRole('heading', { level: 1, name: /Transkriptions-Richtlinien/ })
).toBeVisible();
await expect(page.getByText(/Damit alle Briefe einheitlich/)).toBeVisible();
await expect(page.getByText('Nicht lesbare Wörter')).toBeVisible();
await expect(page.getByText('Durchgestrichene Wörter')).toBeVisible();
await expect(page.getByText(/Das lange s/)).toBeVisible();
await expect(page.getByText('Unsichere Namen')).toBeVisible();
await expect(page.getByText(/Dialekt/)).toBeVisible();
await expect(page.getByText('Abkürzungen')).toBeVisible();
await expect(page.getByText('Datumsformate')).toBeVisible();
await expect(page.getByText(/Fehlt eine Regel/)).toBeVisible();
});
test('Wikipedia link opens in new tab with annotation', async ({ page }) => {
const wikiLink = page.getByRole('link', { name: /Wikipedia/ });
await expect(wikiLink).toHaveAttribute('target', '_blank');
await expect(wikiLink).toHaveAttribute('rel', 'noopener noreferrer');
await expect(wikiLink).toHaveAttribute('referrerpolicy', 'no-referrer');
await expect(wikiLink).toContainText(/öffnet in neuem Tab/);
});
});
test.describe('Richtlinien page — accessibility', () => {
for (const viewport of [320, 768, 1440]) {
test(`axe: light theme at ${viewport}px — no WCAG 2.1 AA violations`, async ({ page }) => {
await page.setViewportSize({ width: viewport, height: 800 });
await page.goto('/hilfe/transkription');
const a11y = await buildAxe(page).analyze();
expect(a11y.violations, JSON.stringify(a11y.violations, null, 2)).toHaveLength(0);
});
test(`axe: dark theme at ${viewport}px — no WCAG 2.1 AA violations`, async ({ page }) => {
await page.setViewportSize({ width: viewport, height: 800 });
await page.goto('/hilfe/transkription');
await page.getByRole('button', { name: /Farbmodus|theme/i }).click();
const a11y = await buildAxe(page).analyze();
expect(a11y.violations, JSON.stringify(a11y.violations, null, 2)).toHaveLength(0);
});
}
});
test.describe('Richtlinien page — print media', () => {
test('print snapshot hides nav, annotation chip, and new-tab spans', async ({ page }) => {
await page.emulateMedia({ media: 'print' });
await page.goto('/hilfe/transkription');
const nav = page.locator('.app-nav');
if ((await nav.count()) > 0) {
await expect(nav).toBeHidden();
}
// .new-tab annotation spans must be hidden in print so "(öffnet in neuem Tab)"
// text does not clutter the printed output (the print CSS declares display:none)
for (const span of await page.locator('.new-tab').all()) {
await expect(span).toBeHidden();
}
await page.screenshot({ path: 'test-results/e2e/richtlinien-print.png', fullPage: true });
});
});

View File

@@ -0,0 +1,105 @@
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
import { createEmptyDocument } from './helpers/upload-empty-document.js';
async function createBlock(
request: Parameters<typeof createEmptyDocument>[0],
docId: string
): Promise<void> {
const res = await request.post(`/api/documents/${docId}/transcription-blocks`, {
data: {
pageNumber: 1,
x: 0.1,
y: 0.1,
width: 0.3,
height: 0.1,
text: 'Liebe Mutter,',
label: null
}
});
if (!res.ok()) throw new Error(`Create block failed: ${res.status()}`);
}
function buildAxe(page: Parameters<typeof AxeBuilder>[0]['page']) {
return new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']);
}
test.describe('Transcribe coach — empty state', () => {
let docId: string;
test.beforeAll(async ({ request }) => {
docId = await createEmptyDocument(request);
});
test.afterAll(async ({ request }) => {
await request.delete(`/api/documents/${docId}`);
});
test('shows coach card (title, preamble, three step bodies, animation region)', async ({
page
}) => {
await page.goto(`/documents/${docId}`);
await page.getByRole('button', { name: 'Transkribieren' }).click();
await expect(page.getByRole('heading', { level: 2, name: /Erste Transkription/ })).toBeVisible({
timeout: 5000
});
await expect(page.getByText(/Kurrent-Erkenner lernt noch/)).toBeVisible();
await expect(page.getByText(/Rahmen ziehen/)).toBeVisible();
await expect(page.getByText(/Text eingeben/)).toBeVisible();
await expect(page.getByText(/Speichert automatisch/)).toBeVisible();
await expect(page.getByRole('img', { name: /Rahmen ziehen|Animation/i })).toBeVisible();
});
test('training footer is NOT visible on empty doc', async ({ page }) => {
await page.goto(`/documents/${docId}`);
await page.getByRole('button', { name: 'Transkribieren' }).click();
await expect(page.getByText('Für Training vormerken')).not.toBeVisible({ timeout: 3000 });
});
test('axe: panel empty state — light theme — no WCAG 2.1 AA violations', async ({ page }) => {
await page.goto(`/documents/${docId}`);
await page.getByRole('button', { name: 'Transkribieren' }).click();
await expect(page.getByRole('heading', { level: 2, name: /Erste Transkription/ })).toBeVisible({
timeout: 5000
});
const a11y = await buildAxe(page).analyze();
expect(a11y.violations, JSON.stringify(a11y.violations, null, 2)).toHaveLength(0);
});
test('axe: panel empty state — dark theme — no WCAG 2.1 AA violations', async ({ page }) => {
await page.goto(`/documents/${docId}`);
// Toggle dark theme
await page.getByRole('button', { name: /dark mode/i }).click();
await page.getByRole('button', { name: 'Transkribieren' }).click();
await expect(page.getByRole('heading', { level: 2, name: /Erste Transkription/ })).toBeVisible({
timeout: 5000
});
const a11y = await buildAxe(page).analyze();
expect(a11y.violations, JSON.stringify(a11y.violations, null, 2)).toHaveLength(0);
});
});
test.describe('Transcribe coach — with blocks', () => {
let docId: string;
test.beforeAll(async ({ request }) => {
docId = await createEmptyDocument(request);
await createBlock(request, docId);
});
test.afterAll(async ({ request }) => {
await request.delete(`/api/documents/${docId}`);
});
test('training footer IS visible when at least one block exists', async ({ page }) => {
await page.goto(`/documents/${docId}`);
await page.getByRole('button', { name: 'Transkribieren' }).click();
// Wait for blocks to finish loading — block count confirms mode settled to 'read'
await expect(page.getByText(/1 Abschnitt/)).toBeVisible({ timeout: 5000 });
await page.locator('[data-testid="mode-edit"]').click();
await expect(page.getByText('Für Training vormerken')).toBeVisible({ timeout: 5000 });
});
});

View File

@@ -165,6 +165,10 @@
"conv_hero_divider": "oder", "conv_hero_divider": "oder",
"conv_empty_recent_label": "Zuletzt geöffnet", "conv_empty_recent_label": "Zuletzt geöffnet",
"conv_no_party": "—", "conv_no_party": "—",
"dist_bar_segment": "{count} von {name}",
"dist_bar_aria": "Briefverteilung in diesem Zeitraum: {outCount} von {senderName}, {inCount} von {receiverName}",
"row_direction_sent": "Gesendet",
"row_direction_received": "Empfangen",
"admin_heading": "Admin Dashboard", "admin_heading": "Admin Dashboard",
"admin_tab_users": "Benutzer", "admin_tab_users": "Benutzer",
"admin_tab_groups": "Gruppen", "admin_tab_groups": "Gruppen",
@@ -495,7 +499,7 @@
"transcription_block_delete_confirm": "Block und alle zugehörigen Kommentare wirklich löschen?", "transcription_block_delete_confirm": "Block und alle zugehörigen Kommentare wirklich löschen?",
"transcription_block_history_btn": "Verlauf", "transcription_block_history_btn": "Verlauf",
"transcription_empty_cta": "Markiere einen Bereich auf dem Scan, um mit der Transkription zu beginnen", "transcription_empty_cta": "Markiere einen Bereich auf dem Scan, um mit der Transkription zu beginnen",
"transcription_next_block_cta": "Markiere eine weitere Passage im Scan, um Block {number} anzulegen", "transcription_next_block_cta": "Einen Rahmen ziehen, um Block {number} anzulegen",
"transcription_draw_tooltip": "Klicken und ziehen, um einen Textbereich zu markieren", "transcription_draw_tooltip": "Klicken und ziehen, um einen Textbereich zu markieren",
"transcription_quote_stale": "Zitat aus älterer Version", "transcription_quote_stale": "Zitat aus älterer Version",
"transcription_block_conflict": "Dieser Block wurde von jemand anderem geändert — bitte neu laden", "transcription_block_conflict": "Dieser Block wurde von jemand anderem geändert — bitte neu laden",
@@ -511,7 +515,6 @@
"scan_collapse": "Scan verkleinern", "scan_collapse": "Scan verkleinern",
"transcription_empty_title": "Noch keine Transkription", "transcription_empty_title": "Noch keine Transkription",
"transcription_empty_desc": "Zeichne Bereiche auf dem Scan und tippe den Text ab, um eine Transkription zu erstellen.", "transcription_empty_desc": "Zeichne Bereiche auf dem Scan und tippe den Text ab, um eine Transkription zu erstellen.",
"transcription_empty_draw_hint": "Zeichnen Sie Bereiche auf dem Dokument, um mit der Transkription zu beginnen.",
"transcription_panel_close": "Panel schließen", "transcription_panel_close": "Panel schließen",
"person_alias_heading": "Namensverlauf", "person_alias_heading": "Namensverlauf",
"person_alias_empty": "Noch keine Namensaenderungen erfasst.", "person_alias_empty": "Noch keine Namensaenderungen erfasst.",
@@ -802,5 +805,98 @@
"chronik_load_more": "Mehr laden", "chronik_load_more": "Mehr laden",
"chronik_loading": "Lädt …", "chronik_loading": "Lädt …",
"chronik_load_more_announcement": "{count} weitere Einträge geladen", "chronik_load_more_announcement": "{count} weitere Einträge geladen",
"chronik_view_all": "Alle Aktivitäten →" "chronik_view_all": "Alle Aktivitäten →",
"pagination_prev": "Zurück",
"pagination_next": "Weiter",
"pagination_page_of": "Seite {page} von {total}",
"pagination_nav_label": "Seitennavigation",
"common_opens_new_tab": "(öffnet in neuem Tab)",
"transcribe_coach_title": "Erste Transkription?",
"transcribe_coach_preamble": "Unser Kurrent-Erkenner lernt noch. Jede Transkription, die Sie zum Training freigeben, bringt ihm die Schrift bei — so funktioniert's:",
"transcribe_coach_step_1_title": "Rahmen ziehen.",
"transcribe_coach_step_1_body": "Klicken und ziehen Sie mit der Maus einen Rahmen um den Text, den Sie transkribieren möchten.",
"transcribe_coach_step_2_title": "Text eingeben.",
"transcribe_coach_step_2_body": "Geben Sie den Text, den Sie im Rahmen sehen, in das neue Textfeld ein.",
"transcribe_coach_step_3_title": "Speichert automatisch.",
"transcribe_coach_footer_kurrent": "Hilfe zu Kurrent ↗",
"transcribe_coach_footer_richtlinien": "Transkriptions-Richtlinien ↗",
"transcription_mode_help_label": "Lese- und Bearbeitungsmodus",
"transcription_mode_help_body": "Lesen zeigt die Transkription als fließenden Text. Bearbeiten öffnet die Textfelder für jede Passage.",
"richtlinien_title": "Transkriptions-Richtlinien",
"richtlinien_intro": "Damit alle Briefe einheitlich transkribiert werden — egal wer tippt — hier unsere Regeln. Die Seite wächst mit: sobald wir eine neue Konvention beschließen, landet sie hier.",
"richtlinien_wiki_text": "Kurrent- und Sütterlin-Alphabete sind bei Wikipedia gut erklärt. Hier stehen nur unsere eigenen Vereinbarungen für dieses Archiv.",
"richtlinien_wiki_link": "Wikipedia",
"richtlinien_rules_label": "Regeln für die Transkription",
"richtlinien_rule_unleserlich_title": "Nicht lesbare Wörter",
"richtlinien_rule_unleserlich_body": "Wenn Sie ein Wort beim besten Willen nicht entziffern können, schreiben Sie [unleserlich]. Jemand anderes schaut später nochmal drauf.",
"richtlinien_rule_durchgestrichen_title": "Durchgestrichene Wörter",
"richtlinien_rule_durchgestrichen_body": "Auch durchgestrichener Text gehört zum Brief. Schreiben Sie ihn in eckigen Klammern mit Präfix durchgestrichen:",
"richtlinien_rule_langes_s_title": "Das lange s (ſ)",
"richtlinien_rule_langes_s_body": "Das ſ ist nur eine alte Schriftform des Buchstabens s — kein eigener Laut. Schreiben Sie immer ein normales s.",
"richtlinien_rule_name_title": "Unsichere Namen",
"richtlinien_rule_name_body": "Wenn Sie einen Namen zu erkennen meinen, aber nicht sicher sind, ergänzen Sie ein Fragezeichen in eckigen Klammern.",
"richtlinien_rule_dialekt_title": "Dialekt, Fremdwörter, fremde Zitate",
"richtlinien_rule_dialekt_body": "Plattdeutsch, Französisch, lateinische Phrasen — wörtlich übernehmen, genau wie sie geschrieben stehen.",
"richtlinien_beispiel_label": "Beispiel",
"richtlinien_klaerung_label": "Noch in Klärung",
"richtlinien_klaerung_intro": "Diese Fragen klären wir noch — stoßen Sie beim Transkribieren darauf, treffen Sie eine plausible Wahl und notieren Sie es in den Kommentaren:",
"richtlinien_klaer_abkuerzungen": "Abkürzungen",
"richtlinien_klaer_datumsformate": "Datumsformate",
"richtlinien_klaer_umbrueche": "Originale Zeilenumbrüche",
"richtlinien_klaer_caps": "Alte Groß-/Kleinschreibung",
"richtlinien_closing_title": "Fehlt eine Regel?",
"richtlinien_closing_body": "Stolpern Sie beim Transkribieren über eine Situation, die hier nicht steht — schreiben Sie einen Kommentar beim betreffenden Block. Wir sammeln sie und besprechen sie beim nächsten Familientreffen.",
"error_batch_too_large": "Zu viele Dateien auf einmal — bitte in Blöcken hochladen.",
"bulk_drop_hint": "Eine oder mehrere Dateien ablegen",
"bulk_drop_sub": "PDF · bis zu 50 MB pro Datei",
"bulk_count_pill": "{count} werden erstellt",
"bulk_save_cta_one": "Speichern →",
"bulk_save_cta": "{count} speichern →",
"bulk_discard_all": "Alle verwerfen",
"bulk_discard_confirm": "Alle Dateien und eingegebenen Daten verwerfen? Diese Aktion kann nicht rückgängig gemacht werden.",
"bulk_add_more": "Weitere hinzufügen",
"bulk_scope_per_file_label": "Nur diese Datei",
"bulk_scope_shared_label": "Gilt für alle {count}",
"bulk_title_suggested_hint": "Vorschlag aus Dateiname — zum Bearbeiten anklicken",
"bulk_switcher_prev": "Vorherige Datei",
"bulk_switcher_next": "Nächste Datei",
"bulk_file_error_chip_label": "Fehler beim Hochladen",
"bulk_upload_progress": "{done} von {total} hochgeladen",
"bulk_partial_success": "{created} erstellt, {failed} fehlgeschlagen",
"bulk_all_failed": "Alle Uploads fehlgeschlagen",
"bulk_drop_desc": "Für jede Datei wird ein eigenes Dokument erstellt. Der Titel wird aus dem Dateinamen vorausgefüllt — alle anderen Felder gelten für alle gemeinsam.",
"bulk_select_files": "Dateien auswählen",
"bulk_drop_zone_label": "Dateien ablegen",
"bulk_remove_file": "Entfernen",
"bulk_title_single": "Neues Dokument",
"bulk_title_multi": "Neue Dokumente",
"bulk_edit_button": "Massenbearbeitung",
"bulk_edit_n_selected_one": "1 Dokument ausgewählt",
"bulk_edit_n_selected_other": "{count} Dokumente ausgewählt",
"bulk_edit_clear_all": "Alles aufheben",
"bulk_edit_all_x": "Alle {count} editieren",
"bulk_edit_select_document": "Dokument {title} auswählen",
"bulk_edit_hint": "Nur ausgefüllte Felder werden angewendet. Tags und Empfänger werden hinzugefügt, nicht ersetzt.",
"bulk_edit_badge_additive": "+ wird hinzugefügt",
"bulk_edit_badge_replace": "wird ersetzt",
"bulk_edit_save_progress": "Batch {done} von {total} verarbeitet",
"bulk_edit_save_partial": "{done} von {total} gespeichert",
"bulk_edit_retry": "Erneut versuchen",
"bulk_edit_title": "Massenbearbeitung",
"bulk_edit_save_button": "Anwenden",
"error_bulk_edit_too_many_ids": "Maximal 500 Dokumente pro Anfrage.",
"form_label_archive_box": "Karton",
"form_helper_archive_box": "Welcher Karton im Archiv?",
"form_label_archive_folder": "Mappe",
"form_helper_archive_folder": "Welche Mappe innerhalb des Kartons?",
"bulk_edit_clear_selection": "Auswahl aufheben",
"bulk_edit_clear_hint_keyboard": "Esc: Auswahl aufheben",
"bulk_edit_loading": "Dokumente werden geladen…",
"bulk_edit_all_x_failed": "Filter konnte nicht abgerufen werden — bitte erneut versuchen.",
"bulk_edit_topbar_title": "Massenbearbeitung",
"bulk_edit_count_pill": "{count} werden bearbeitet"
} }

View File

@@ -165,6 +165,10 @@
"conv_hero_divider": "or", "conv_hero_divider": "or",
"conv_empty_recent_label": "Recently opened", "conv_empty_recent_label": "Recently opened",
"conv_no_party": "—", "conv_no_party": "—",
"dist_bar_segment": "{count} from {name}",
"dist_bar_aria": "Letter distribution in this period: {outCount} from {senderName}, {inCount} from {receiverName}",
"row_direction_sent": "Sent",
"row_direction_received": "Received",
"admin_heading": "Admin Dashboard", "admin_heading": "Admin Dashboard",
"admin_tab_users": "Users", "admin_tab_users": "Users",
"admin_tab_groups": "Groups", "admin_tab_groups": "Groups",
@@ -495,7 +499,7 @@
"transcription_block_delete_confirm": "Really delete this block and all its comments?", "transcription_block_delete_confirm": "Really delete this block and all its comments?",
"transcription_block_history_btn": "History", "transcription_block_history_btn": "History",
"transcription_empty_cta": "Mark a region on the scan to start transcribing", "transcription_empty_cta": "Mark a region on the scan to start transcribing",
"transcription_next_block_cta": "Mark another passage on the scan to create block {number}", "transcription_next_block_cta": "Draw a frame on the scan to create block {number}",
"transcription_draw_tooltip": "Click and drag to mark a text region", "transcription_draw_tooltip": "Click and drag to mark a text region",
"transcription_quote_stale": "Quote from an older version", "transcription_quote_stale": "Quote from an older version",
"transcription_block_conflict": "This block was changed by someone else — please reload", "transcription_block_conflict": "This block was changed by someone else — please reload",
@@ -511,7 +515,6 @@
"scan_collapse": "Collapse scan", "scan_collapse": "Collapse scan",
"transcription_empty_title": "No transcription yet", "transcription_empty_title": "No transcription yet",
"transcription_empty_desc": "Draw regions on the scan and type the text to create a transcription.", "transcription_empty_desc": "Draw regions on the scan and type the text to create a transcription.",
"transcription_empty_draw_hint": "Draw regions on the document to start transcribing.",
"transcription_panel_close": "Close panel", "transcription_panel_close": "Close panel",
"person_alias_heading": "Name history", "person_alias_heading": "Name history",
"person_alias_empty": "No name changes recorded yet.", "person_alias_empty": "No name changes recorded yet.",
@@ -802,5 +805,98 @@
"chronik_load_more": "Load more", "chronik_load_more": "Load more",
"chronik_loading": "Loading …", "chronik_loading": "Loading …",
"chronik_load_more_announcement": "{count} more entries loaded", "chronik_load_more_announcement": "{count} more entries loaded",
"chronik_view_all": "All activity →" "chronik_view_all": "All activity →",
"pagination_prev": "Previous",
"pagination_next": "Next",
"pagination_page_of": "Page {page} of {total}",
"pagination_nav_label": "Pagination",
"common_opens_new_tab": "(opens in new tab)",
"transcribe_coach_title": "First transcription?",
"transcribe_coach_preamble": "Our Kurrent recogniser is still learning. Every transcription you release for training teaches it the handwriting — here's how it works:",
"transcribe_coach_step_1_title": "Draw a frame.",
"transcribe_coach_step_1_body": "Click and drag a frame around the text you want to transcribe.",
"transcribe_coach_step_2_title": "Enter the text.",
"transcribe_coach_step_2_body": "Type the text you see inside the frame into the new text field.",
"transcribe_coach_step_3_title": "Saves automatically.",
"transcribe_coach_footer_kurrent": "Kurrent help ↗",
"transcribe_coach_footer_richtlinien": "Transcription guidelines ↗",
"transcription_mode_help_label": "Read and edit mode",
"transcription_mode_help_body": "Read shows the transcription as flowing text. Edit opens the text fields for each passage.",
"richtlinien_title": "Transcription Guidelines",
"richtlinien_intro": "So every letter is transcribed consistently — no matter who types — here are our rules. The page grows with us: as soon as we agree a new convention, it lands here.",
"richtlinien_wiki_text": "The Kurrent and Sütterlin alphabets are well explained on Wikipedia. Here you'll only find our own conventions for this archive.",
"richtlinien_wiki_link": "Wikipedia",
"richtlinien_rules_label": "Transcription rules",
"richtlinien_rule_unleserlich_title": "Illegible words",
"richtlinien_rule_unleserlich_body": "If you can't decipher a word even after trying, write [unleserlich]. Someone else will take another look later.",
"richtlinien_rule_durchgestrichen_title": "Struck-through words",
"richtlinien_rule_durchgestrichen_body": "Struck-through text still belongs to the letter. Write it in square brackets with prefix durchgestrichen:",
"richtlinien_rule_langes_s_title": "The long s (ſ)",
"richtlinien_rule_langes_s_body": "The ſ is just an old written form of the letter s — not a separate sound. Always write a normal s.",
"richtlinien_rule_name_title": "Uncertain names",
"richtlinien_rule_name_body": "If you think you can read a name but aren't sure, add a question mark in square brackets.",
"richtlinien_rule_dialekt_title": "Dialect, foreign words, foreign quotes",
"richtlinien_rule_dialekt_body": "Low German, French, Latin phrases — copy them verbatim, exactly as written.",
"richtlinien_beispiel_label": "Example",
"richtlinien_klaerung_label": "Still to be decided",
"richtlinien_klaerung_intro": "These questions are still open — if you hit one while transcribing, make a plausible choice and note it in the comments:",
"richtlinien_klaer_abkuerzungen": "Abbreviations",
"richtlinien_klaer_datumsformate": "Date formats",
"richtlinien_klaer_umbrueche": "Original line breaks",
"richtlinien_klaer_caps": "Old capitalisation",
"richtlinien_closing_title": "Missing a rule?",
"richtlinien_closing_body": "If you hit a situation while transcribing that isn't listed here — leave a comment on the relevant block. We collect them and discuss them at the next family gathering.",
"error_batch_too_large": "Too many files at once — please upload in smaller batches.",
"bulk_drop_hint": "Drop one or more files here",
"bulk_drop_sub": "PDF · up to 50 MB per file",
"bulk_count_pill": "{count} will be created",
"bulk_save_cta_one": "Save →",
"bulk_save_cta": "Save {count} →",
"bulk_discard_all": "Discard all",
"bulk_discard_confirm": "Discard all files and entered data? This action cannot be undone.",
"bulk_add_more": "Add more",
"bulk_scope_per_file_label": "This file only",
"bulk_scope_shared_label": "Applies to all {count}",
"bulk_title_suggested_hint": "Suggested from filename — click to edit",
"bulk_switcher_prev": "Previous file",
"bulk_switcher_next": "Next file",
"bulk_file_error_chip_label": "Upload failed",
"bulk_upload_progress": "{done} of {total} uploaded",
"bulk_partial_success": "{created} created, {failed} failed",
"bulk_all_failed": "All uploads failed",
"bulk_drop_desc": "A separate document is created for each file. The title is pre-filled from the filename — all other fields apply to all documents.",
"bulk_select_files": "Select files",
"bulk_drop_zone_label": "Drop files here",
"bulk_remove_file": "Remove",
"bulk_title_single": "New Document",
"bulk_title_multi": "New Documents",
"bulk_edit_button": "Bulk edit",
"bulk_edit_n_selected_one": "1 document selected",
"bulk_edit_n_selected_other": "{count} documents selected",
"bulk_edit_clear_all": "Clear all",
"bulk_edit_all_x": "Edit all {count}",
"bulk_edit_select_document": "Select document {title}",
"bulk_edit_hint": "Only filled fields are applied. Tags and receivers are added, not replaced.",
"bulk_edit_badge_additive": "+ will be added",
"bulk_edit_badge_replace": "will replace",
"bulk_edit_save_progress": "Batch {done} of {total} processed",
"bulk_edit_save_partial": "{done} of {total} saved",
"bulk_edit_retry": "Retry",
"bulk_edit_title": "Bulk edit",
"bulk_edit_save_button": "Apply",
"error_bulk_edit_too_many_ids": "Maximum 500 documents per request.",
"form_label_archive_box": "Box",
"form_helper_archive_box": "Which box in the archive?",
"form_label_archive_folder": "Folder",
"form_helper_archive_folder": "Which folder inside the box?",
"bulk_edit_clear_selection": "Clear selection",
"bulk_edit_clear_hint_keyboard": "Esc: clear selection",
"bulk_edit_loading": "Loading documents…",
"bulk_edit_all_x_failed": "Could not load filter results — please retry.",
"bulk_edit_topbar_title": "Bulk edit",
"bulk_edit_count_pill": "{count} will be edited"
} }

View File

@@ -165,6 +165,10 @@
"conv_hero_divider": "o", "conv_hero_divider": "o",
"conv_empty_recent_label": "Recientemente abiertos", "conv_empty_recent_label": "Recientemente abiertos",
"conv_no_party": "—", "conv_no_party": "—",
"dist_bar_segment": "{count} de {name}",
"dist_bar_aria": "Distribución de cartas en este período: {outCount} de {senderName}, {inCount} de {receiverName}",
"row_direction_sent": "Enviada",
"row_direction_received": "Recibida",
"admin_heading": "Panel de administración", "admin_heading": "Panel de administración",
"admin_tab_users": "Usuarios", "admin_tab_users": "Usuarios",
"admin_tab_groups": "Grupos", "admin_tab_groups": "Grupos",
@@ -495,7 +499,7 @@
"transcription_block_delete_confirm": "¿Realmente eliminar este bloque y todos sus comentarios?", "transcription_block_delete_confirm": "¿Realmente eliminar este bloque y todos sus comentarios?",
"transcription_block_history_btn": "Historial", "transcription_block_history_btn": "Historial",
"transcription_empty_cta": "Marque una región en el escaneo para comenzar a transcribir", "transcription_empty_cta": "Marque una región en el escaneo para comenzar a transcribir",
"transcription_next_block_cta": "Marque otro pasaje en el escaneo para crear el bloque {number}", "transcription_next_block_cta": "Dibuje un marco en el escáner para crear el bloque {number}",
"transcription_draw_tooltip": "Haga clic y arrastre para marcar una región de texto", "transcription_draw_tooltip": "Haga clic y arrastre para marcar una región de texto",
"transcription_quote_stale": "Cita de una versión anterior", "transcription_quote_stale": "Cita de una versión anterior",
"transcription_block_conflict": "Este bloque fue cambiado por otra persona — por favor recargue", "transcription_block_conflict": "Este bloque fue cambiado por otra persona — por favor recargue",
@@ -511,7 +515,6 @@
"scan_collapse": "Reducir escaneo", "scan_collapse": "Reducir escaneo",
"transcription_empty_title": "Sin transcripcion", "transcription_empty_title": "Sin transcripcion",
"transcription_empty_desc": "Dibuja regiones en el escaneo y escribe el texto para crear una transcripcion.", "transcription_empty_desc": "Dibuja regiones en el escaneo y escribe el texto para crear una transcripcion.",
"transcription_empty_draw_hint": "Dibuje regiones en el documento para comenzar a transcribir.",
"transcription_panel_close": "Cerrar panel", "transcription_panel_close": "Cerrar panel",
"person_alias_heading": "Historial de nombres", "person_alias_heading": "Historial de nombres",
"person_alias_empty": "Aun no se han registrado cambios de nombre.", "person_alias_empty": "Aun no se han registrado cambios de nombre.",
@@ -802,5 +805,98 @@
"chronik_load_more": "Cargar más", "chronik_load_more": "Cargar más",
"chronik_loading": "Cargando …", "chronik_loading": "Cargando …",
"chronik_load_more_announcement": "{count} entradas más cargadas", "chronik_load_more_announcement": "{count} entradas más cargadas",
"chronik_view_all": "Todas las actividades →" "chronik_view_all": "Todas las actividades →",
"pagination_prev": "Anterior",
"pagination_next": "Siguiente",
"pagination_page_of": "Página {page} de {total}",
"pagination_nav_label": "Paginación",
"common_opens_new_tab": "(abre en pestaña nueva)",
"transcribe_coach_title": "¿Primera transcripción?",
"transcribe_coach_preamble": "Nuestro reconocedor de Kurrent aún está aprendiendo. Cada transcripción que libera para el entrenamiento le enseña la escritura — así funciona:",
"transcribe_coach_step_1_title": "Dibujar un marco.",
"transcribe_coach_step_1_body": "Haga clic y arrastre un marco alrededor del texto que desea transcribir.",
"transcribe_coach_step_2_title": "Ingresar el texto.",
"transcribe_coach_step_2_body": "Escriba el texto que ve dentro del marco en el nuevo campo de texto.",
"transcribe_coach_step_3_title": "Se guarda automáticamente.",
"transcribe_coach_footer_kurrent": "Ayuda sobre Kurrent ↗",
"transcribe_coach_footer_richtlinien": "Normas de transcripción ↗",
"transcription_mode_help_label": "Modo lectura y edición",
"transcription_mode_help_body": "Lectura muestra la transcripción como texto continuo. Edición abre los campos de texto para cada pasaje.",
"richtlinien_title": "Normas de transcripción",
"richtlinien_intro": "Para que todas las cartas se transcriban de forma uniforme — sin importar quién transcriba — aquí están nuestras reglas. La página crece con nosotros.",
"richtlinien_wiki_text": "Los alfabetos Kurrent y Sütterlin están bien explicados en Wikipedia. Aquí solo se recogen nuestros propios acuerdos para este archivo.",
"richtlinien_wiki_link": "Wikipedia",
"richtlinien_rules_label": "Reglas de transcripción",
"richtlinien_rule_unleserlich_title": "Palabras ilegibles",
"richtlinien_rule_unleserlich_body": "Si no puedes descifrar una palabra, escribe [unleserlich]. Otra persona lo revisará después.",
"richtlinien_rule_durchgestrichen_title": "Palabras tachadas",
"richtlinien_rule_durchgestrichen_body": "El texto tachado también pertenece a la carta. Escríbelo entre corchetes con el prefijo durchgestrichen:",
"richtlinien_rule_langes_s_title": "La s larga (ſ)",
"richtlinien_rule_langes_s_body": "La ſ es solo una forma antigua de la letra s. Escribe siempre una s normal.",
"richtlinien_rule_name_title": "Nombres inciertos",
"richtlinien_rule_name_body": "Si crees reconocer un nombre pero no estás seguro, añade un signo de interrogación entre corchetes.",
"richtlinien_rule_dialekt_title": "Dialecto, palabras extranjeras, citas",
"richtlinien_rule_dialekt_body": "Bajo alemán, francés, frases latinas — cópialas tal cual están escritas.",
"richtlinien_beispiel_label": "Ejemplo",
"richtlinien_klaerung_label": "Aún por decidir",
"richtlinien_klaerung_intro": "Estas preguntas aún están abiertas — si encuentras alguna mientras transcribes, elige algo razonable y nótalo en los comentarios:",
"richtlinien_klaer_abkuerzungen": "Abreviaturas",
"richtlinien_klaer_datumsformate": "Formatos de fecha",
"richtlinien_klaer_umbrueche": "Saltos de línea originales",
"richtlinien_klaer_caps": "Mayúsculas antiguas",
"richtlinien_closing_title": "¿Falta una regla?",
"richtlinien_closing_body": "Si al transcribir encuentras una situación que no está aquí — deja un comentario en el bloque. Las recogemos y las discutimos en la próxima reunión familiar.",
"error_batch_too_large": "Demasiados archivos a la vez — sube en lotes más pequeños.",
"bulk_drop_hint": "Suelta uno o varios archivos aquí",
"bulk_drop_sub": "PDF · hasta 50 MB por archivo",
"bulk_count_pill": "Se crearán {count}",
"bulk_save_cta_one": "Guardar →",
"bulk_save_cta": "Guardar {count} →",
"bulk_discard_all": "Descartar todo",
"bulk_discard_confirm": "¿Descartar todos los archivos y datos introducidos? Esta acción no se puede deshacer.",
"bulk_add_more": "Añadir más",
"bulk_scope_per_file_label": "Solo este archivo",
"bulk_scope_shared_label": "Para todos los {count}",
"bulk_title_suggested_hint": "Sugerencia del nombre de archivo — haz clic para editar",
"bulk_switcher_prev": "Archivo anterior",
"bulk_switcher_next": "Archivo siguiente",
"bulk_file_error_chip_label": "Error al subir",
"bulk_upload_progress": "{done} de {total} subidos",
"bulk_partial_success": "{created} creados, {failed} fallidos",
"bulk_all_failed": "Todos los uploads fallaron",
"bulk_drop_desc": "Se crea un documento separado por archivo. El título se rellena desde el nombre del archivo — el resto de campos se aplican a todos.",
"bulk_select_files": "Seleccionar archivos",
"bulk_drop_zone_label": "Soltar archivos aquí",
"bulk_remove_file": "Eliminar",
"bulk_title_single": "Nuevo Documento",
"bulk_title_multi": "Nuevos Documentos",
"bulk_edit_button": "Edición masiva",
"bulk_edit_n_selected_one": "1 documento seleccionado",
"bulk_edit_n_selected_other": "{count} documentos seleccionados",
"bulk_edit_clear_all": "Limpiar todo",
"bulk_edit_all_x": "Editar los {count}",
"bulk_edit_select_document": "Seleccionar documento {title}",
"bulk_edit_hint": "Solo se aplican los campos rellenados. Las etiquetas y los destinatarios se añaden, no se reemplazan.",
"bulk_edit_badge_additive": "+ se añade",
"bulk_edit_badge_replace": "se reemplaza",
"bulk_edit_save_progress": "Lote {done} de {total} procesado",
"bulk_edit_save_partial": "{done} de {total} guardado",
"bulk_edit_retry": "Reintentar",
"bulk_edit_title": "Edición masiva",
"bulk_edit_save_button": "Aplicar",
"error_bulk_edit_too_many_ids": "Máximo 500 documentos por solicitud.",
"form_label_archive_box": "Caja",
"form_helper_archive_box": "¿Qué caja del archivo?",
"form_label_archive_folder": "Carpeta",
"form_helper_archive_folder": "¿Qué carpeta dentro de la caja?",
"bulk_edit_clear_selection": "Limpiar selección",
"bulk_edit_clear_hint_keyboard": "Esc: limpiar selección",
"bulk_edit_loading": "Cargando documentos…",
"bulk_edit_all_x_failed": "No se pudieron cargar los resultados del filtro; vuelve a intentarlo.",
"bulk_edit_topbar_title": "Edición masiva",
"bulk_edit_count_pill": "Se editarán {count}"
} }

View File

@@ -26,6 +26,7 @@ export default defineConfig({
use: { use: {
baseURL: process.env.E2E_BASE_URL ?? 'http://localhost:3000', baseURL: process.env.E2E_BASE_URL ?? 'http://localhost:3000',
locale: 'de-DE', // ensures Accept-Language: de is sent so locale detection defaults to German locale: 'de-DE', // ensures Accept-Language: de is sent so locale detection defaults to German
reducedMotion: 'reduce', // prevents SMIL/CSS animations from flaking tests
screenshot: 'on', // always capture screenshots screenshot: 'on', // always capture screenshots
video: 'retain-on-failure', video: 'retain-on-failure',
trace: 'retain-on-failure' trace: 'retain-on-failure'

View File

@@ -0,0 +1,45 @@
<script lang="ts">
import type { components } from '$lib/generated/api';
type Doc = Pick<
components['schemas']['Document'],
'id' | 'thumbnailUrl' | 'thumbnailAspect' | 'pageCount'
>;
let { doc }: { doc: Doc } = $props();
const url = $derived(doc.thumbnailUrl ?? null);
const aspect = $derived(doc.thumbnailAspect ?? 'PORTRAIT');
const pageCount = $derived(doc.pageCount ?? 1);
const tileClass = $derived(aspect === 'LANDSCAPE' ? 'h-[120px] w-[168px]' : 'h-[168px] w-[120px]');
</script>
<div
data-testid="conv-thumb-tile"
data-aspect={aspect}
class="relative {tileClass} flex-shrink-0 overflow-hidden rounded-sm border border-line bg-white"
>
{#if url}
<img
src={url}
alt=""
class="h-full w-full object-cover object-top dark:mix-blend-multiply"
loading="lazy"
decoding="async"
/>
{:else}
<div
data-testid="conv-thumb-skeleton"
class="h-full w-full bg-line/60 motion-safe:animate-pulse"
aria-hidden="true"
></div>
{/if}
{#if pageCount > 1}
<span
data-testid="conv-thumb-page-badge"
class="absolute top-1 right-1 rounded-full bg-primary/90 px-2 py-1 text-sm leading-none font-bold text-surface"
>{pageCount}</span
>
{/if}
</div>

View File

@@ -0,0 +1,112 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import ConversationThumbnail from './ConversationThumbnail.svelte';
afterEach(() => {
cleanup();
});
describe('ConversationThumbnail', () => {
it('renders the thumbnail image with a cache-busting v= query param', () => {
render(ConversationThumbnail, {
doc: {
id: '1111',
thumbnailUrl: '/api/documents/1111/thumbnail?v=2026-04-10T09%3A00%3A00Z',
thumbnailAspect: 'PORTRAIT',
pageCount: 1
}
});
const img = document.querySelector('img') as HTMLImageElement | null;
expect(img).not.toBeNull();
expect(img!.getAttribute('src')).toContain('/api/documents/1111/thumbnail');
expect(img!.getAttribute('src')).toContain('v=');
});
it('uses portrait dimensions when aspect is PORTRAIT', () => {
render(ConversationThumbnail, {
doc: {
id: 'p1',
thumbnailUrl: '/api/documents/p1/thumbnail?v=2026-04-10T09%3A00%3A00Z',
thumbnailAspect: 'PORTRAIT',
pageCount: 1
}
});
const tile = document.querySelector('[data-testid="conv-thumb-tile"]') as HTMLElement;
expect(tile.getAttribute('data-aspect')).toBe('PORTRAIT');
});
it('uses landscape dimensions when aspect is LANDSCAPE', () => {
render(ConversationThumbnail, {
doc: {
id: 'l1',
thumbnailUrl: '/api/documents/l1/thumbnail?v=2026-04-10T09%3A00%3A00Z',
thumbnailAspect: 'LANDSCAPE',
pageCount: 1
}
});
const tile = document.querySelector('[data-testid="conv-thumb-tile"]') as HTMLElement;
expect(tile.getAttribute('data-aspect')).toBe('LANDSCAPE');
});
it('falls back to PORTRAIT when thumbnailAspect is missing', () => {
render(ConversationThumbnail, {
doc: {
id: 'n1',
thumbnailUrl: '/api/documents/n1/thumbnail?v=2026-04-10T09%3A00%3A00Z'
}
});
const tile = document.querySelector('[data-testid="conv-thumb-tile"]') as HTMLElement;
expect(tile.getAttribute('data-aspect')).toBe('PORTRAIT');
});
it('renders the page badge when pageCount is greater than 1', () => {
render(ConversationThumbnail, {
doc: {
id: 'm1',
thumbnailUrl: '/api/documents/m1/thumbnail?v=2026-04-10T09%3A00%3A00Z',
thumbnailAspect: 'PORTRAIT',
pageCount: 4
}
});
const badge = document.querySelector('[data-testid="conv-thumb-page-badge"]') as HTMLElement;
expect(badge).not.toBeNull();
expect(badge.textContent).toContain('4');
// Senior-readable size: text-sm (14px) rather than text-xs (12px) on a
// small tile avoids marginal legibility on a 320px phone.
expect(badge.className).toContain('text-sm');
});
it('hides the page badge when pageCount is 1 or missing', () => {
render(ConversationThumbnail, {
doc: {
id: 's1',
thumbnailUrl: '/api/documents/s1/thumbnail?v=2026-04-10T09%3A00%3A00Z',
thumbnailAspect: 'PORTRAIT',
pageCount: 1
}
});
const badge = document.querySelector('[data-testid="conv-thumb-page-badge"]');
expect(badge).toBeNull();
});
it('renders a skeleton placeholder when no thumbnailUrl is set yet', () => {
render(ConversationThumbnail, {
doc: {
id: 'blank',
thumbnailAspect: 'PORTRAIT'
}
});
expect(document.querySelector('img')).toBeNull();
const skeleton = document.querySelector('[data-testid="conv-thumb-skeleton"]');
expect(skeleton).not.toBeNull();
expect(skeleton!.className).toContain('motion-safe:animate-pulse');
});
});

View File

@@ -44,27 +44,41 @@ function safeColor(color: string): string {
</div> </div>
{:else} {:else}
<div data-testid="resume-strip" class="flex gap-4 rounded-sm border border-line bg-surface p-5"> <div data-testid="resume-strip" class="flex gap-4 rounded-sm border border-line bg-surface p-5">
<svg <div
xmlns="http://www.w3.org/2000/svg" class="relative h-[252px] w-[180px] flex-shrink-0 overflow-hidden rounded-sm border border-line bg-white"
width="180"
height="246"
viewBox="0 0 180 246"
aria-hidden="true"
class="shrink-0"
> >
<defs> {#if resumeDoc.thumbnailUrl}
<linearGradient id="parchment" x1="0" y1="0" x2="0" y2="1"> <img
<stop offset="0%" stop-color="#f5f0e8" /> data-testid="resume-thumbnail-img"
<stop offset="100%" stop-color="#ede8d5" /> src={resumeDoc.thumbnailUrl}
</linearGradient> alt=""
</defs> class="h-full w-full object-cover object-top dark:mix-blend-multiply"
<rect width="180" height="246" fill="url(#parchment)" /> loading="lazy"
<line x1="30" y1="40" x2="150" y2="40" stroke="#b0a898" stroke-width="1" /> decoding="async"
<line x1="30" y1="70" x2="150" y2="70" stroke="#b0a898" stroke-width="1" /> />
<line x1="30" y1="100" x2="150" y2="100" stroke="#b0a898" stroke-width="1" /> {:else}
<line x1="30" y1="130" x2="150" y2="130" stroke="#b0a898" stroke-width="1" /> <div
<line x1="30" y1="160" x2="150" y2="160" stroke="#b0a898" stroke-width="1" /> data-testid="resume-thumbnail-fallback"
</svg> class="flex h-full w-full items-center justify-center text-ink-3"
aria-hidden="true"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="h-24 w-24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z M9 12.75h6M9 15.75h6M9 18.75h3"
/>
</svg>
</div>
{/if}
</div>
<div class="flex flex-1 flex-col gap-2"> <div class="flex flex-1 flex-col gap-2">
<p class="flex items-center gap-1.5 font-sans text-xs text-ink-3"> <p class="flex items-center gap-1.5 font-sans text-xs text-ink-3">

View File

@@ -21,6 +21,11 @@ const mockResume: DashboardResumeDTO = {
collaborators: [] collaborators: []
}; };
const mockResumeWithThumbnail: DashboardResumeDTO = {
...mockResume,
thumbnailUrl: '/api/documents/doc-123/thumbnail?v=2026-04-23T09%3A00'
};
describe('DashboardResumeStrip', () => { describe('DashboardResumeStrip', () => {
it('renders empty state heading when resumeDoc is null', async () => { it('renders empty state heading when resumeDoc is null', async () => {
render(DashboardResumeStrip, { resumeDoc: null }); render(DashboardResumeStrip, { resumeDoc: null });
@@ -52,4 +57,23 @@ describe('DashboardResumeStrip', () => {
const label = page.getByText(/4 Abschnitte/i); const label = page.getByText(/4 Abschnitte/i);
await expect.element(label).toBeInTheDocument(); await expect.element(label).toBeInTheDocument();
}); });
it('renders thumbnail img with expected attrs when thumbnailUrl is set', async () => {
render(DashboardResumeStrip, { resumeDoc: mockResumeWithThumbnail });
const img = page.getByTestId('resume-thumbnail-img');
await expect.element(img).toBeInTheDocument();
await expect
.element(img)
.toHaveAttribute('src', '/api/documents/doc-123/thumbnail?v=2026-04-23T09%3A00');
await expect.element(img).toHaveAttribute('alt', '');
await expect.element(img).toHaveAttribute('loading', 'lazy');
await expect.element(img).toHaveAttribute('decoding', 'async');
});
it('renders fallback icon when thumbnailUrl is null', async () => {
render(DashboardResumeStrip, { resumeDoc: mockResume });
const fallback = page.getByTestId('resume-thumbnail-fallback');
await expect.element(fallback).toBeInTheDocument();
await expect.element(page.getByTestId('resume-thumbnail-img')).not.toBeInTheDocument();
});
}); });

View File

@@ -0,0 +1,60 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
interface Props {
outCount: number;
inCount: number;
senderName: string;
receiverName: string;
}
let { outCount, inCount, senderName, receiverName }: Props = $props();
const total = $derived(outCount + inCount);
const outPct = $derived(total > 0 ? (outCount / total) * 100 : 0);
const shortSenderName = $derived(senderName.split(' ')[0] ?? senderName);
const shortReceiverName = $derived(receiverName.split(' ')[0] ?? receiverName);
const ariaLabel = $derived(m.dist_bar_aria({ outCount, senderName, inCount, receiverName }));
const outSegmentText = $derived(m.dist_bar_segment({ count: outCount, name: shortSenderName }));
const inSegmentText = $derived(m.dist_bar_segment({ count: inCount, name: shortReceiverName }));
</script>
<div
class="flex flex-col gap-1 border-b border-line bg-muted px-[18px] py-2"
role="img"
aria-label={ariaLabel}
>
<div class="flex justify-between text-sm font-bold">
<span class="inline-flex items-center gap-1 text-primary"
>{outSegmentText}
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Long-Arrow/Long-Arrow-Right-MD.svg"
alt=""
aria-hidden="true"
class="inline h-3.5 w-3.5 opacity-60"
/></span
>
<span class="inline-flex items-center gap-1 text-accent"
>{inSegmentText}
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Long-Arrow/Long-Arrow-Left-MD.svg"
alt=""
aria-hidden="true"
class="inline h-3.5 w-3.5 opacity-60"
/></span
>
</div>
<div class="flex h-[5px] overflow-hidden rounded-full bg-line">
<div
data-testid="dist-bar-segment"
class="h-full bg-primary transition-all"
style="width: {outPct}%"
></div>
<div
data-testid="dist-bar-segment"
class="h-full bg-accent transition-all"
style="width: {100 - outPct}%"
></div>
</div>
</div>

View File

@@ -0,0 +1,71 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import * as m from '$lib/paraglide/messages.js';
import DistributionBar from './DistributionBar.svelte';
afterEach(() => {
cleanup();
});
describe('DistributionBar', () => {
it('renders the Paraglide aria-label and visible segments', async () => {
render(DistributionBar, {
outCount: 3,
inCount: 7,
senderName: 'Hans Müller',
receiverName: 'Anna Schmidt'
});
const container = document.querySelector('[role="img"]') as HTMLElement;
expect(container).toBeTruthy();
// The aria-label must come from Paraglide, not a hardcoded German string,
// so the EN / ES users aren't served "Briefverteilung in diesem Zeitraum".
const expectedAria = m.dist_bar_aria({
outCount: 3,
senderName: 'Hans Müller',
inCount: 7,
receiverName: 'Anna Schmidt'
});
expect(container.getAttribute('aria-label')).toBe(expectedAria);
// The visible "{count} from/von {name}" spans must also come from Paraglide.
const outText = m.dist_bar_segment({ count: 3, name: 'Hans' });
const inText = m.dist_bar_segment({ count: 7, name: 'Anna' });
expect(container.textContent).toContain(outText);
expect(container.textContent).toContain(inText);
// 3/10 → 30% / 70% split on the two segments
const segments = container.querySelectorAll('[data-testid="dist-bar-segment"]');
expect(segments).toHaveLength(2);
expect((segments[0] as HTMLElement).style.width).toBe('30%');
expect((segments[1] as HTMLElement).style.width).toBe('70%');
});
it('falls back to the full name when it has no space to split', async () => {
render(DistributionBar, {
outCount: 1,
inCount: 0,
senderName: 'SingleWord',
receiverName: 'Another'
});
const container = document.querySelector('[role="img"]') as HTMLElement;
const expected = m.dist_bar_segment({ count: 1, name: 'SingleWord' });
expect(container.textContent).toContain(expected);
});
it('renders a zero-percent left segment when outCount is zero', async () => {
render(DistributionBar, {
outCount: 0,
inCount: 4,
senderName: 'Hans',
receiverName: 'Anna'
});
const segments = document.querySelectorAll('[data-testid="dist-bar-segment"]');
expect((segments[0] as HTMLElement).style.width).toBe('0%');
expect((segments[1] as HTMLElement).style.width).toBe('100%');
});
});

View File

@@ -4,13 +4,14 @@ import type { components } from '$lib/generated/api';
import { applyOffsets } from '$lib/search'; import { applyOffsets } from '$lib/search';
import { formatDate } from '$lib/utils/date'; import { formatDate } from '$lib/utils/date';
import * as m from '$lib/paraglide/messages.js'; import * as m from '$lib/paraglide/messages.js';
import { bulkSelectionStore } from '$lib/stores/bulkSelection.svelte';
import ProgressRing from './ProgressRing.svelte'; import ProgressRing from './ProgressRing.svelte';
import ContributorStack from './ContributorStack.svelte'; import ContributorStack from './ContributorStack.svelte';
import DocumentThumbnail from './DocumentThumbnail.svelte'; import DocumentThumbnail from './DocumentThumbnail.svelte';
type DocumentSearchItem = components['schemas']['DocumentSearchItem']; type DocumentSearchItem = components['schemas']['DocumentSearchItem'];
let { item }: { item: DocumentSearchItem } = $props(); let { item, canWrite = false }: { item: DocumentSearchItem; canWrite?: boolean } = $props();
const doc = $derived(item.document); const doc = $derived(item.document);
const titleText = $derived(doc.title || doc.originalFilename); const titleText = $derived(doc.title || doc.originalFilename);
@@ -55,6 +56,21 @@ function safeTagColor(color: string | null | undefined): string {
<a href="/documents/{doc.id}" aria-label={titleText} class="absolute inset-0 z-0 block"></a> <a href="/documents/{doc.id}" aria-label={titleText} class="absolute inset-0 z-0 block"></a>
<div class="pointer-events-none relative z-10 px-4 py-4 sm:py-5"> <div class="pointer-events-none relative z-10 px-4 py-4 sm:py-5">
<div class="flex gap-3 sm:gap-5"> <div class="flex gap-3 sm:gap-5">
<!-- Bulk-selection checkbox -->
{#if canWrite}
<label
class="pointer-events-auto flex min-h-[44px] min-w-[44px] flex-shrink-0 cursor-pointer items-start pt-1"
data-testid="bulk-select-checkbox"
>
<input
type="checkbox"
class="h-5 w-5 cursor-pointer accent-brand-navy"
checked={bulkSelectionStore.has(doc.id)}
onchange={() => bulkSelectionStore.toggle(doc.id)}
aria-label={m.bulk_edit_select_document({ title: titleText })}
/>
</label>
{/if}
<!-- Thumbnail tile --> <!-- Thumbnail tile -->
<DocumentThumbnail doc={doc} size="lg" /> <DocumentThumbnail doc={doc} size="lg" />

View File

@@ -3,6 +3,7 @@ import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser'; import { page } from 'vitest/browser';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import DocumentRow from './DocumentRow.svelte'; import DocumentRow from './DocumentRow.svelte';
import { bulkSelectionStore } from '$lib/stores/bulkSelection.svelte';
import type { components } from '$lib/generated/api'; import type { components } from '$lib/generated/api';
vi.mock('$app/navigation', () => ({ goto: vi.fn() })); vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
@@ -10,6 +11,7 @@ vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
vi.mocked(goto).mockClear(); vi.mocked(goto).mockClear();
bulkSelectionStore.clear();
}); });
type DocumentSearchItem = components['schemas']['DocumentSearchItem']; type DocumentSearchItem = components['schemas']['DocumentSearchItem'];
@@ -265,6 +267,45 @@ describe('DocumentRow tags', () => {
}); });
}); });
// ─── Bulk-selection checkbox ─────────────────────────────────────────────────
describe('DocumentRow bulk selection checkbox', () => {
it('does not render the checkbox when canWrite is false', async () => {
render(DocumentRow, { item: makeItem(), canWrite: false });
await expect.element(page.getByTestId('bulk-select-checkbox')).not.toBeInTheDocument();
});
it('renders the checkbox when canWrite is true', async () => {
render(DocumentRow, { item: makeItem(), canWrite: true });
await expect.element(page.getByTestId('bulk-select-checkbox')).toBeInTheDocument();
});
it('checkbox aria-label includes the document title', async () => {
const item = makeItem({ document: { ...makeItem().document, title: 'Brief an Anna' } });
render(DocumentRow, { item, canWrite: true });
await expect
.element(page.getByRole('checkbox', { name: /Brief an Anna/i }))
.toBeInTheDocument();
});
it('toggling the checkbox calls bulkSelectionStore.toggle', async () => {
const item = makeItem({ document: { ...makeItem().document, id: 'doc-42' } });
render(DocumentRow, { item, canWrite: true });
expect(bulkSelectionStore.has('doc-42')).toBe(false);
document.querySelector<HTMLInputElement>('input[type="checkbox"]')?.click();
await expect.poll(() => bulkSelectionStore.has('doc-42')).toBe(true);
});
it('checked state mirrors the store', async () => {
bulkSelectionStore.add('doc-99');
const item = makeItem({ document: { ...makeItem().document, id: 'doc-99' } });
render(DocumentRow, { item, canWrite: true });
await expect.element(page.getByRole('checkbox')).toBeChecked();
});
});
// ─── ProgressRing & ContributorStack ───────────────────────────────────────── // ─── ProgressRing & ContributorStack ─────────────────────────────────────────
describe('DocumentRow progress ring and contributors', () => { describe('DocumentRow progress ring and contributors', () => {

View File

@@ -1,14 +1,10 @@
<script lang="ts"> <script lang="ts">
import type { components } from '$lib/generated/api'; import type { components } from '$lib/generated/api';
import { thumbnailUrl } from '$lib/thumbnails';
type Doc = Pick< type Doc = Pick<components['schemas']['Document'], 'id' | 'thumbnailUrl' | 'contentType'>;
components['schemas']['Document'],
'id' | 'thumbnailKey' | 'thumbnailGeneratedAt' | 'contentType'
>;
let { doc, size = 'sm' }: { doc: Doc; size?: 'sm' | 'lg' } = $props(); let { doc, size = 'sm' }: { doc: Doc; size?: 'sm' | 'lg' } = $props();
const url = $derived(thumbnailUrl(doc)); const url = $derived(doc.thumbnailUrl ?? null);
const containerClass = $derived( const containerClass = $derived(
size === 'lg' size === 'lg'

View File

@@ -0,0 +1,101 @@
<script module>
// Module-level counter produces stable, predictable IDs across SSR + hydration.
// Math.random() would generate different values server-side vs client-side,
// causing a hydration mismatch on first render.
let _counter = 0;
</script>
<script lang="ts">
import type { Snippet } from 'svelte';
type Placement = 'bottom' | 'top' | 'left' | 'right';
type Props = {
label: string;
placement?: Placement;
children?: Snippet;
};
let { label, placement = 'bottom', children }: Props = $props();
const popoverId = `help-popover-${_counter++}`;
let open = $state(false);
let triggerEl: HTMLButtonElement | null = $state(null);
function toggle() {
open = !open;
}
function close() {
open = false;
triggerEl?.focus();
}
$effect(() => {
if (!open) return;
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.preventDefault();
close();
}
}
function onPointerDown(e: PointerEvent) {
if (triggerEl && (e.target === triggerEl || triggerEl.contains(e.target as Node))) return;
const popoverEl = document.getElementById(popoverId);
if (popoverEl && popoverEl.contains(e.target as Node)) return;
open = false;
}
document.addEventListener('keydown', onKeyDown);
document.addEventListener('pointerdown', onPointerDown);
return () => {
document.removeEventListener('keydown', onKeyDown);
document.removeEventListener('pointerdown', onPointerDown);
};
});
const placementClass: Record<Placement, string> = {
bottom: 'top-full mt-1.5 left-1/2 -translate-x-1/2',
top: 'bottom-full mb-1.5 left-1/2 -translate-x-1/2',
left: 'right-full mr-1.5 top-1/2 -translate-y-1/2',
right: 'left-full ml-1.5 top-1/2 -translate-y-1/2'
};
</script>
<div class="relative inline-block">
<!--
Outer button is 44×44px for WCAG 2.5.8 touch-target compliance (our transcriber
audience is 60+). The inner <span> carries the visual 20×20px circle.
-->
<button
bind:this={triggerEl}
type="button"
aria-label={label}
aria-expanded={open}
aria-controls={popoverId}
onclick={toggle}
class="group flex h-[44px] w-[44px] items-center justify-center rounded-full focus-visible:ring-2 focus-visible:ring-brand-navy"
>
<span
class="flex h-5 w-5 items-center justify-center rounded-full border border-line bg-muted font-sans text-[10px] font-bold text-ink-3 transition-colors group-hover:border-brand-navy group-hover:text-brand-navy"
>
?
</span>
</button>
{#if open}
<div
id={popoverId}
role="region"
aria-label={label}
class="absolute z-50 w-64 rounded-sm border border-line bg-white p-3 font-sans text-sm text-ink shadow-md {placementClass[placement]}"
>
{#if children}
{@render children()}
{/if}
</div>
{/if}
</div>

View File

@@ -0,0 +1,114 @@
import { describe, it, expect, afterEach, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser';
import HelpPopover from './HelpPopover.svelte';
afterEach(cleanup);
function renderPopover(label = 'Help') {
return render(HelpPopover, { props: { label } });
}
describe('HelpPopover — initial state', () => {
it('renders a trigger button with the given label', async () => {
renderPopover();
const btn = page.getByRole('button', { name: /Help/ });
await expect.element(btn).toBeInTheDocument();
});
it('starts closed: aria-expanded is false, popover not in DOM', async () => {
renderPopover();
const btn = page.getByRole('button', { name: /Help/ });
await expect.element(btn).toHaveAttribute('aria-expanded', 'false');
expect(document.querySelector('[role="region"]')).toBeNull();
});
});
describe('HelpPopover — open / close interactions', () => {
it('opens on click: aria-expanded true, popover in DOM', async () => {
renderPopover();
await page.getByRole('button', { name: /Help/ }).click();
const btn = page.getByRole('button', { name: /Help/ });
await expect.element(btn).toHaveAttribute('aria-expanded', 'true');
expect(document.querySelector('[role="region"]')).not.toBeNull();
});
it('closes on Esc key', async () => {
renderPopover();
await page.getByRole('button', { name: /Help/ }).click();
expect(document.querySelector('[role="region"]')).not.toBeNull();
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await vi.waitFor(() => expect(document.querySelector('[role="region"]')).toBeNull());
});
it('closes on outside click', async () => {
renderPopover();
await page.getByRole('button', { name: /Help/ }).click();
expect(document.querySelector('[role="region"]')).not.toBeNull();
document.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }));
await vi.waitFor(() => expect(document.querySelector('[role="region"]')).toBeNull());
});
it('opens on Enter key', async () => {
renderPopover();
(document.querySelector('button[aria-expanded]') as HTMLButtonElement).focus();
await userEvent.keyboard('{Enter}');
await vi.waitFor(() => expect(document.querySelector('[role="region"]')).not.toBeNull());
});
it('opens on Space key', async () => {
renderPopover();
(document.querySelector('button[aria-expanded]') as HTMLButtonElement).focus();
await userEvent.keyboard('{Space}');
await vi.waitFor(() => expect(document.querySelector('[role="region"]')).not.toBeNull());
});
});
describe('HelpPopover — hover-target', () => {
it('hover styles propagate from 44px button group to inner span, not from span itself', () => {
const { container } = renderPopover();
const btn = container.querySelector('button[aria-expanded]')!;
const span = btn.querySelector('span')!;
const btnClasses = btn.className.split(/\s+/);
const spanClasses = span.className.split(/\s+/);
expect(btnClasses).toContain('group');
expect(spanClasses).not.toContain('hover:border-brand-navy');
expect(spanClasses).toContain('group-hover:border-brand-navy');
expect(spanClasses).not.toContain('hover:text-brand-navy');
expect(spanClasses).toContain('group-hover:text-brand-navy');
});
it('outer button has focus-visible ring for keyboard users', () => {
const { container } = renderPopover();
const btn = container.querySelector('button[aria-expanded]')!;
expect(btn.className).toContain('focus-visible:ring-2');
expect(btn.className).toContain('focus-visible:ring-brand-navy');
});
});
describe('HelpPopover — aria wiring', () => {
it('trigger aria-controls matches popover element id', async () => {
renderPopover();
await page.getByRole('button', { name: /Help/ }).click();
const btn = document.querySelector('button[aria-expanded]') as HTMLButtonElement;
const controls = btn.getAttribute('aria-controls');
expect(controls).toBeTruthy();
const popover = document.getElementById(controls!);
expect(popover).not.toBeNull();
});
it('two renders produce different, predictable IDs (no Math.random — SSR safe)', async () => {
const { container: c1 } = render(HelpPopover, { props: { label: 'A' } });
const { container: c2 } = render(HelpPopover, { props: { label: 'B' } });
const id1 = c1.querySelector('button[aria-controls]')?.getAttribute('aria-controls');
const id2 = c2.querySelector('button[aria-controls]')?.getAttribute('aria-controls');
expect(id1).toBeTruthy();
expect(id2).toBeTruthy();
expect(id1).not.toBe(id2);
// IDs must be deterministic (counter-based), not random hex
expect(id1).toMatch(/^help-popover-\d+$/);
expect(id2).toMatch(/^help-popover-\d+$/);
});
});

View File

@@ -0,0 +1,80 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
interface Props {
/** 0-indexed current page. */
page: number;
/** Total number of pages. `0` or `1` hides the control as trivially there's nothing to navigate. */
totalPages: number;
/** Given a 0-indexed page number, returns the href the link should point at. */
makeHref: (page: number) => string;
/** Optional override for the outer `<nav>`'s aria-label. */
ariaLabel?: string;
}
const { page, totalPages, makeHref, ariaLabel }: Props = $props();
const hasPrev = $derived(page > 0);
const hasNext = $derived(page < totalPages - 1);
const controlBase =
'inline-flex min-h-[44px] min-w-[44px] items-center justify-center gap-1.5 rounded-sm border border-line bg-white px-4 py-2 font-sans text-sm font-bold text-ink';
const linkBase = `${controlBase} transition-colors hover:bg-surface focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-2 focus-visible:outline-none`;
const disabledBase = `${controlBase} cursor-not-allowed opacity-40`;
</script>
{#if totalPages > 1}
<nav
aria-label={ariaLabel ?? m.pagination_nav_label()}
class="mt-6 flex flex-col items-center gap-3 sm:flex-row sm:justify-between"
>
<!--
At the bounds we render a <span aria-hidden="true"> instead of an
<a aria-disabled>. aria-disabled on a link is the documented pattern
but screen readers still announce "Previous, link, disabled" — which
is confusing on a pagination control where the disabled state is
purely visual. Hiding the element from the AT tree entirely is the
cleaner semantic.
-->
{#if hasPrev}
<a
data-testid="pagination-prev"
aria-label={m.pagination_prev()}
href={makeHref(page - 1)}
class={linkBase}
>
<span aria-hidden="true">«</span>
{m.pagination_prev()}
</a>
{:else}
<span data-testid="pagination-prev" aria-hidden="true" class={disabledBase}>
<span aria-hidden="true">«</span>
{m.pagination_prev()}
</span>
{/if}
<span
data-testid="pagination-page-label"
aria-current="page"
class="font-sans text-sm text-ink-2"
>
{m.pagination_page_of({ page: page + 1, total: totalPages })}
</span>
{#if hasNext}
<a
data-testid="pagination-next"
aria-label={m.pagination_next()}
href={makeHref(page + 1)}
class={linkBase}
>
{m.pagination_next()}
<span aria-hidden="true">»</span>
</a>
{:else}
<span data-testid="pagination-next" aria-hidden="true" class={disabledBase}>
{m.pagination_next()}
<span aria-hidden="true">»</span>
</span>
{/if}
</nav>
{/if}

View File

@@ -0,0 +1,86 @@
import { describe, it, expect, afterEach, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import Pagination from './Pagination.svelte';
afterEach(() => {
cleanup();
});
const makeHref = (p: number) => `/documents?page=${p}`;
describe('Pagination', () => {
it('renders the page-of-total label for the current page', async () => {
render(Pagination, { page: 2, totalPages: 10, makeHref });
const label = page.getByTestId('pagination-page-label');
await expect.element(label).toHaveTextContent(/3/); // page is 0-indexed, label is 1-indexed
await expect.element(label).toHaveTextContent(/10/);
});
it('marks the current page label with aria-current="page"', async () => {
render(Pagination, { page: 0, totalPages: 3, makeHref });
const label = page.getByTestId('pagination-page-label');
await expect.element(label).toHaveAttribute('aria-current', 'page');
});
it('renders prev as a link pointing at page - 1 when not on first page', async () => {
render(Pagination, { page: 4, totalPages: 10, makeHref });
const prev = page.getByTestId('pagination-prev');
await expect.element(prev).toHaveAttribute('href', '/documents?page=3');
});
it('renders disabled prev as an aria-hidden non-link so screen readers skip it', async () => {
render(Pagination, { page: 0, totalPages: 3, makeHref });
const prev = page.getByTestId('pagination-prev');
// Not a link — no href, no role=link
await expect.element(prev).not.toHaveAttribute('href');
// Hidden from assistive tech — AT shouldn't read "Previous, link, disabled"
await expect.element(prev).toHaveAttribute('aria-hidden', 'true');
});
it('renders next as a link pointing at page + 1 when not on last page', async () => {
render(Pagination, { page: 0, totalPages: 3, makeHref });
const next = page.getByTestId('pagination-next');
await expect.element(next).toHaveAttribute('href', '/documents?page=1');
});
it('renders disabled next as an aria-hidden non-link on the last page', async () => {
render(Pagination, { page: 2, totalPages: 3, makeHref });
const next = page.getByTestId('pagination-next');
await expect.element(next).not.toHaveAttribute('href');
await expect.element(next).toHaveAttribute('aria-hidden', 'true');
});
it('calls makeHref with p-1 and p+1', async () => {
const spy = vi.fn(makeHref);
render(Pagination, { page: 3, totalPages: 10, makeHref: spy });
const calls = spy.mock.calls.map((c) => c[0]).sort((a, b) => a - b);
expect(calls).toContain(2);
expect(calls).toContain(4);
});
it('renders decorative chevron inside aria-hidden span so screen readers skip it', async () => {
render(Pagination, { page: 1, totalPages: 3, makeHref });
const prev = page.getByTestId('pagination-prev');
await expect
.element(prev.getByText('«', { exact: true }))
.toHaveAttribute('aria-hidden', 'true');
});
it('prev and next have min 44px touch targets', async () => {
render(Pagination, { page: 1, totalPages: 3, makeHref });
const prev = page.getByTestId('pagination-prev');
await expect.element(prev).toHaveClass(/min-h-\[44px\]/);
await expect.element(prev).toHaveClass(/min-w-\[44px\]/);
});
});

View File

@@ -67,7 +67,7 @@ function removePerson(id: string | undefined) {
<div class="relative" use:clickOutside onclickoutside={() => (showDropdown = false)}> <div class="relative" use:clickOutside onclickoutside={() => (showDropdown = false)}>
<div <div
class="flex min-h-[42px] flex-wrap gap-2 rounded border border-line bg-surface p-2 focus-within:border-ink focus-within:ring-1 focus-within:ring-ink" class="flex min-h-[38px] flex-wrap gap-2 rounded border border-line bg-surface p-2 shadow-sm focus-within:ring-2 focus-within:ring-focus-ring focus-within:outline-none"
> >
{#each selectedPersons as person (person.id)} {#each selectedPersons as person (person.id)}
<span <span

View File

@@ -4,6 +4,7 @@ import type { components } from '$lib/generated/api';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import { clickOutside } from '$lib/actions/clickOutside'; import { clickOutside } from '$lib/actions/clickOutside';
import { createTypeahead } from '$lib/hooks/useTypeahead.svelte'; import { createTypeahead } from '$lib/hooks/useTypeahead.svelte';
import FieldLabelBadge from './document/FieldLabelBadge.svelte';
type Person = components['schemas']['Person']; type Person = components['schemas']['Person'];
interface Props { interface Props {
@@ -18,6 +19,7 @@ interface Props {
autofocus?: boolean; autofocus?: boolean;
required?: boolean; required?: boolean;
restrictToCorrespondentsOf?: string; restrictToCorrespondentsOf?: string;
badge?: 'additive' | 'replace';
onchange?: (value: string) => void; onchange?: (value: string) => void;
onfocused?: () => void; onfocused?: () => void;
} }
@@ -34,6 +36,7 @@ let {
autofocus = false, autofocus = false,
required = false, required = false,
restrictToCorrespondentsOf, restrictToCorrespondentsOf,
badge,
onchange, onchange,
onfocused onfocused
}: Props = $props(); }: Props = $props();
@@ -116,7 +119,7 @@ function selectPerson(person: Person) {
class={compact class={compact
? 'block text-xs font-bold tracking-wide text-ink-3 uppercase' ? 'block text-xs font-bold tracking-wide text-ink-3 uppercase'
: 'block text-sm font-medium text-ink-2'} : 'block text-sm font-medium text-ink-2'}
>{label}{#if required}*{/if}</label >{label}{#if required}*{/if}{#if badge}<FieldLabelBadge variant={badge} />{/if}</label
> >
<input type="hidden" name={name} bind:value={value} /> <input type="hidden" name={name} bind:value={value} />
@@ -134,7 +137,7 @@ function selectPerson(person: Person) {
? 'mt-2 block h-14 w-full rounded-md border border-line bg-surface px-4 text-base text-ink shadow-sm placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring' ? 'mt-2 block h-14 w-full rounded-md border border-line bg-surface px-4 text-base text-ink shadow-sm placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'
: compact : compact
? 'mt-1 block h-9 w-full rounded border border-line bg-surface px-2 text-sm text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring' ? 'mt-1 block h-9 w-full rounded border border-line bg-surface px-2 text-sm text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'
: 'mt-1 block w-full rounded-md border border-line bg-surface p-2 text-ink shadow-sm placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'} : 'mt-1 block w-full rounded border border-line bg-surface px-2 py-3 text-sm text-ink shadow-sm placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'}
/> />
{#if typeahead.isOpen && (typeahead.results.length > 0 || typeahead.loading)} {#if typeahead.isOpen && (typeahead.results.length > 0 || typeahead.loading)}

View File

@@ -0,0 +1,46 @@
<script lang="ts">
type Props = {
icon: string;
title: string;
body: string;
beispielInput?: string;
beispielInputStrike?: boolean;
beispielOutput?: string;
beispielLabel?: string;
};
let {
icon,
title,
body,
beispielInput,
beispielInputStrike = false,
beispielOutput,
beispielLabel = 'Beispiel'
}: Props = $props();
</script>
<div class="border-brand-sand break-inside-avoid rounded-sm border bg-white p-5 shadow-sm">
<div class="mb-3 flex items-center gap-2">
<span aria-hidden="true" class="text-xl">{icon}</span>
<h3 class="font-serif text-base font-bold text-ink">{title}</h3>
</div>
<p class="font-serif text-sm leading-relaxed text-ink-2">{body}</p>
{#if beispielOutput !== undefined}
<div class="border-brand-sand mt-4 rounded-sm border bg-parchment px-4 py-3">
<p class="font-sans text-xs font-semibold tracking-wider text-ink-3 uppercase">
{beispielLabel}
</p>
<p class="mt-1 font-sans text-sm text-ink">
{#if beispielInput !== undefined}
<code
class={['font-mono', beispielInputStrike && 'line-through'].filter(Boolean).join(' ')}
>{beispielInput}</code
>
{/if}
<code class="font-mono">{beispielOutput}</code>
</p>
</div>
{/if}
</div>

View File

@@ -0,0 +1,49 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import RichtlinienRuleCard from './RichtlinienRuleCard.svelte';
afterEach(cleanup);
const defaultProps = {
icon: '✍',
title: 'Unleserliche Wörter',
body: 'Schreiben Sie [unleserlich].',
beispielOutput: '[unleserlich]'
};
describe('RichtlinienRuleCard', () => {
it('renders an h3 with the title', async () => {
render(RichtlinienRuleCard, { props: defaultProps });
await expect
.element(page.getByRole('heading', { level: 3 }))
.toHaveTextContent('Unleserliche Wörter');
});
it('renders the body text', async () => {
render(RichtlinienRuleCard, { props: defaultProps });
await expect.element(page.getByText('Schreiben Sie [unleserlich].')).toBeInTheDocument();
});
it('renders icon in a span with aria-hidden="true"', async () => {
render(RichtlinienRuleCard, { props: defaultProps });
const iconSpan = document.querySelector('span[aria-hidden="true"]');
expect(iconSpan).not.toBeNull();
expect(iconSpan!.textContent).toContain('✍');
});
it('renders beispielOutput in monospace with → arrow', async () => {
render(RichtlinienRuleCard, { props: defaultProps });
const mono = document.querySelector('code, [class*="font-mono"]');
expect(mono).not.toBeNull();
expect(mono!.textContent).toContain('[unleserlich]');
await expect.element(page.getByText(/→/)).toBeInTheDocument();
});
it('does not render beispiel section when beispielOutput is absent', async () => {
render(RichtlinienRuleCard, {
props: { icon: '✍', title: 'Test', body: 'Body' }
});
expect(document.querySelector('code, [class*="font-mono"]')).toBeNull();
});
});

View File

@@ -0,0 +1,23 @@
<script lang="ts">
type Tag = { id: string; name: string };
let { tags, max = 3 }: { tags: Tag[]; max?: number } = $props();
const displayedTags = $derived(tags.slice(0, max));
const hiddenTagCount = $derived(Math.max(0, tags.length - max));
</script>
{#if tags.length > 0}
<div class="flex flex-wrap items-center gap-1 pt-0.5">
{#each displayedTags as tag (tag.id)}
<span
data-testid="thumb-row-tag"
class="max-w-[140px] truncate rounded-full border border-line bg-surface px-2 py-0.5 text-sm text-ink-2"
>{tag.name}</span
>
{/each}
{#if hiddenTagCount > 0}
<span class="text-sm font-bold text-ink-3">+{hiddenTagCount}</span>
{/if}
</div>
{/if}

View File

@@ -0,0 +1,41 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import TagChipList from './TagChipList.svelte';
afterEach(() => {
cleanup();
});
const makeTags = (n: number) =>
Array.from({ length: n }, (_, i) => ({ id: `t${i}`, name: `Tag${i}` }));
describe('TagChipList', () => {
it('renders all tags as chips when under the cap', () => {
render(TagChipList, { tags: makeTags(2), max: 3 });
const chips = document.querySelectorAll('[data-testid="thumb-row-tag"]');
expect(chips).toHaveLength(2);
expect(document.body.textContent).not.toMatch(/\+/);
});
it('caps visible chips at max and renders +N for the remainder', () => {
render(TagChipList, { tags: makeTags(5), max: 3 });
const chips = document.querySelectorAll('[data-testid="thumb-row-tag"]');
expect(chips).toHaveLength(3);
expect(document.body.textContent).toMatch(/\+2/);
});
it('renders nothing when tags is empty', () => {
render(TagChipList, { tags: [], max: 3 });
const chips = document.querySelectorAll('[data-testid="thumb-row-tag"]');
expect(chips).toHaveLength(0);
expect(document.body.textContent).not.toMatch(/\+/);
});
it('defaults max to 3 when the prop is omitted', () => {
render(TagChipList, { tags: makeTags(5) });
const chips = document.querySelectorAll('[data-testid="thumb-row-tag"]');
expect(chips).toHaveLength(3);
expect(document.body.textContent).toMatch(/\+2/);
});
});

View File

@@ -0,0 +1,98 @@
<script lang="ts">
import ConversationThumbnail from '$lib/components/ConversationThumbnail.svelte';
import TagChipList from '$lib/components/TagChipList.svelte';
import { formatDate } from '$lib/utils/date';
import * as m from '$lib/paraglide/messages.js';
type Person = { id: string; firstName?: string | null; lastName: string; displayName: string };
type Tag = { id: string; name: string };
type Doc = {
id: string;
title?: string;
originalFilename: string;
documentDate?: string;
location?: string;
summary?: string;
contentType?: string;
thumbnailKey?: string;
thumbnailGeneratedAt?: string;
thumbnailAspect?: 'PORTRAIT' | 'LANDSCAPE';
pageCount?: number;
sender?: Person | null;
receivers?: Person[];
tags?: Tag[];
};
let {
doc,
isOut,
showOtherParty
}: {
doc: Doc;
isOut: boolean;
showOtherParty: boolean;
} = $props();
const title = $derived(doc.title || doc.originalFilename);
const otherPartyName = $derived(
showOtherParty
? isOut
? (doc.receivers?.[0]?.displayName ?? '')
: (doc.sender?.displayName ?? '')
: ''
);
const directionLabel = $derived(isOut ? m.row_direction_sent() : m.row_direction_received());
const ariaLabel = $derived(
`${directionLabel}: ${title}${doc.documentDate ? `, ${formatDate(doc.documentDate)}` : ''}`
);
</script>
<a
href={`/documents/${doc.id}`}
aria-label={ariaLabel}
class="group flex min-h-[120px] items-start gap-3 border-b border-l-[3px] border-line-2 px-[14px] py-[10px] transition-colors last:border-b-0 focus-within:bg-muted hover:bg-muted focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-primary"
class:border-l-primary={isOut}
class:border-l-accent={!isOut}
>
<ConversationThumbnail doc={doc} />
<div class="flex min-w-0 flex-1 flex-col gap-1.5">
<div class="flex min-w-0 items-center gap-2">
<img
data-testid="thumb-row-direction-icon"
src={isOut
? '/degruyter-icons/Simple/Medium-24px/SVG/Action/Long-Arrow/Long-Arrow-Right-MD.svg'
: '/degruyter-icons/Simple/Medium-24px/SVG/Action/Long-Arrow/Long-Arrow-Left-MD.svg'}
alt=""
aria-hidden="true"
class="h-5 w-5 shrink-0 opacity-70"
class:text-primary={isOut}
class:text-accent={!isOut}
/>
<div class="min-w-0 flex-1 truncate text-lg font-bold text-ink">
{title}
</div>
</div>
{#if doc.summary}
<div class="line-clamp-2 text-base text-ink-2 italic">
&ldquo;{doc.summary}&rdquo;
</div>
{/if}
<div class="flex flex-wrap items-center gap-x-[6px] gap-y-1 text-sm text-ink-3">
<span>{doc.documentDate ? formatDate(doc.documentDate) : '—'}</span>
{#if doc.location}
<span class="text-line">·</span>
<span>{doc.location}</span>
{/if}
{#if otherPartyName}
<span class="text-line">·</span>
<span>{otherPartyName}</span>
{/if}
</div>
<TagChipList tags={doc.tags ?? []} />
</div>
</a>

View File

@@ -0,0 +1,228 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import * as m from '$lib/paraglide/messages.js';
import ThumbnailRow from './ThumbnailRow.svelte';
afterEach(() => {
cleanup();
});
const baseDoc = {
id: 'd1',
title: 'Liebe Anna',
originalFilename: 'liebe_anna.pdf',
documentDate: '1950-06-01',
location: 'Berlin',
summary: 'Heute schreibe ich Dir, weil die Kinder gesund sind.',
contentType: 'application/pdf',
thumbnailKey: 'thumbnails/d1.jpg',
thumbnailGeneratedAt: '2026-04-01T12:00:00Z',
thumbnailAspect: 'PORTRAIT' as const,
pageCount: 2,
sender: { id: 'hans', firstName: 'Hans', lastName: 'Müller', displayName: 'Hans Müller' },
receivers: [{ id: 'anna', firstName: 'Anna', lastName: 'Schmidt', displayName: 'Anna Schmidt' }],
tags: [
{ id: 't1', name: 'Familie' },
{ id: 't2', name: 'Krieg' },
{ id: 't3', name: 'Reise' },
{ id: 't4', name: 'Arbeit' },
{ id: 't5', name: 'Zuhause' }
]
};
describe('ThumbnailRow', () => {
it('renders the title, date, location, and summary quote', () => {
render(ThumbnailRow, {
doc: baseDoc,
isOut: true,
showOtherParty: false
});
expect(document.body.textContent).toContain('Liebe Anna');
expect(document.body.textContent).toContain('Berlin');
expect(document.body.textContent).toContain('Heute schreibe ich Dir');
});
it('falls back to originalFilename when title is empty', () => {
render(ThumbnailRow, {
doc: { ...baseDoc, title: '' },
isOut: true,
showOtherParty: false
});
expect(document.body.textContent).toContain('liebe_anna.pdf');
});
it('shows the other-party name when showOtherParty=true (non-bilateral list)', () => {
render(ThumbnailRow, {
doc: baseDoc,
isOut: true,
showOtherParty: true
});
// Out-going from Hans, other party is first receiver (Anna Schmidt)
expect(document.body.textContent).toContain('Anna Schmidt');
});
it('hides the other-party name when showOtherParty=false (bilateral list)', () => {
render(ThumbnailRow, {
doc: baseDoc,
isOut: false,
showOtherParty: false
});
// Anna is the receiver; in a bilateral list we suppress party names.
expect(document.body.textContent).not.toContain('Anna Schmidt');
});
it('renders at most 3 tag chips and signals any remainder with "+N"', () => {
render(ThumbnailRow, {
doc: baseDoc,
isOut: true,
showOtherParty: false
});
const chips = document.querySelectorAll('[data-testid="thumb-row-tag"]');
expect(chips.length).toBeLessThanOrEqual(3);
expect(document.body.textContent).toMatch(/\+2/);
});
it('does not render a relative-year label', () => {
// Document date is historical; we deliberately omit the "vor N Jahren"
// chip so the row can give vertical space to the title + summary.
render(ThumbnailRow, {
doc: baseDoc,
isOut: true,
showOtherParty: false
});
expect(document.body.textContent).not.toMatch(/vor \d+ Jahr/);
expect(document.body.textContent).not.toMatch(/vor weniger/);
});
it('renders the title at text-lg so the row uses its full vertical space', () => {
render(ThumbnailRow, {
doc: baseDoc,
isOut: true,
showOtherParty: false
});
const titleEl = [...document.querySelectorAll('div')].find(
(el) => el.textContent?.trim() === 'Liebe Anna' && el.className.includes('truncate')
) as HTMLElement | undefined;
expect(titleEl, 'title element not found').toBeDefined();
expect(titleEl!.className).toContain('text-lg');
});
it('renders a right-arrow icon for outgoing letters', () => {
render(ThumbnailRow, {
doc: baseDoc,
isOut: true,
showOtherParty: false
});
const arrow = document.querySelector(
'[data-testid="thumb-row-direction-icon"]'
) as HTMLImageElement | null;
expect(arrow).not.toBeNull();
expect(arrow!.getAttribute('src') ?? '').toMatch(/Long-Arrow-Right/);
// Decorative — direction is already announced via the aria-label prefix.
expect(arrow!.getAttribute('aria-hidden')).toBe('true');
});
it('renders a left-arrow icon for incoming letters', () => {
render(ThumbnailRow, {
doc: baseDoc,
isOut: false,
showOtherParty: false
});
const arrow = document.querySelector(
'[data-testid="thumb-row-direction-icon"]'
) as HTMLImageElement | null;
expect(arrow).not.toBeNull();
expect(arrow!.getAttribute('src') ?? '').toMatch(/Long-Arrow-Left/);
});
it('sets border-l class based on isOut', () => {
const { unmount } = render(ThumbnailRow, {
doc: baseDoc,
isOut: true,
showOtherParty: false
});
let link = document.querySelector('a[href="/documents/d1"]') as HTMLElement;
expect(link.className).toContain('border-l-primary');
unmount();
render(ThumbnailRow, {
doc: baseDoc,
isOut: false,
showOtherParty: false
});
link = document.querySelector('a[href="/documents/d1"]') as HTMLElement;
expect(link.className).toContain('border-l-accent');
});
it('exposes a descriptive aria-label combining direction, title, and date', () => {
render(ThumbnailRow, {
doc: baseDoc,
isOut: true,
showOtherParty: false
});
const link = document.querySelector('a[href="/documents/d1"]') as HTMLElement;
const label = link.getAttribute('aria-label') ?? '';
// Direction label routes through Paraglide so EN / ES users don't hear
// "Gesendet" in their screen reader.
expect(label.startsWith(`${m.row_direction_sent()}:`)).toBe(true);
expect(label).toContain('Liebe Anna');
expect(label).toMatch(/1950/);
});
it('aria-label leads with the received direction label for incoming letters', () => {
render(ThumbnailRow, {
doc: baseDoc,
isOut: false,
showOtherParty: false
});
const link = document.querySelector('a[href="/documents/d1"]') as HTMLElement;
const label = link.getAttribute('aria-label') ?? '';
expect(label.startsWith(`${m.row_direction_received()}:`)).toBe(true);
});
it('does not inject raw HTML when summary contains markup (XSS regression)', () => {
render(ThumbnailRow, {
doc: {
...baseDoc,
summary: 'safe <img src=x onerror="alert(1)"> text'
},
isOut: true,
showOtherParty: false
});
// No real img tag from the summary, the ConversationThumbnail img is fine.
const imgs = document.querySelectorAll('img[onerror]');
expect(imgs.length).toBe(0);
expect(document.body.textContent).toContain('<img src=x onerror="alert(1)">');
});
it('handles missing optional fields without crashing', () => {
render(ThumbnailRow, {
doc: {
id: 'n1',
title: 'Ohne Datum',
originalFilename: 'x.pdf',
contentType: 'application/pdf',
thumbnailAspect: 'PORTRAIT'
},
isOut: true,
showOtherParty: false
});
expect(document.body.textContent).toContain('Ohne Datum');
});
});

View File

@@ -0,0 +1,76 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import TranscribeDragDemo from './TranscribeDragDemo.svelte';
</script>
<div class="border-brand-sand rounded-sm border bg-white p-7 shadow-sm">
<h2 class="mb-3 font-serif text-[22px] font-bold text-ink">
{m.transcribe_coach_title()}
</h2>
<p class="mb-6 font-serif text-[15px] leading-relaxed text-ink-2">
{m.transcribe_coach_preamble()}
</p>
<ol class="m-0 flex list-none flex-col gap-[18px] p-0">
<!-- Step 1 -->
<li aria-label="Schritt 1 von 3" class="grid grid-cols-[34px_1fr] items-start gap-3.5">
<span
aria-hidden="true"
class="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full bg-ink font-sans text-sm font-bold text-white"
>1</span
>
<div class="pt-0.5 font-serif text-base leading-snug text-ink">
<strong>{m.transcribe_coach_step_1_title()}</strong>
{m.transcribe_coach_step_1_body()}
<TranscribeDragDemo />
</div>
</li>
<!-- Step 2 -->
<li aria-label="Schritt 2 von 3" class="grid grid-cols-[34px_1fr] items-start gap-3.5">
<span
aria-hidden="true"
class="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full bg-ink font-sans text-sm font-bold text-white"
>2</span
>
<div class="pt-0.5 font-serif text-base leading-snug text-ink">
<strong>{m.transcribe_coach_step_2_title()}</strong>
{m.transcribe_coach_step_2_body()}
</div>
</li>
<!-- Step 3 -->
<li aria-label="Schritt 3 von 3" class="grid grid-cols-[34px_1fr] items-start gap-3.5">
<span
aria-hidden="true"
class="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full bg-ink font-sans text-sm font-bold text-white"
>3</span
>
<div class="pt-0.5 font-serif text-base leading-snug text-ink">
<strong>{m.transcribe_coach_step_3_title()}</strong>
</div>
</li>
</ol>
<div class="border-brand-sand mt-6 flex flex-wrap gap-4 border-t pt-3.5 font-sans text-[13px]">
<a
href="https://de.wikipedia.org/wiki/Kurrent"
target="_blank"
rel="noopener noreferrer"
referrerpolicy="no-referrer"
class="text-ink underline decoration-brand-mint decoration-[1.5px] underline-offset-[3px]"
>
{m.transcribe_coach_footer_kurrent()}
<span class="ml-1 text-[11px] text-ink-3">{m.common_opens_new_tab()}</span>
</a>
<a
href="/hilfe/transkription"
target="_blank"
rel="noopener noreferrer"
class="text-ink underline decoration-brand-mint decoration-[1.5px] underline-offset-[3px]"
>
{m.transcribe_coach_footer_richtlinien()}
<span class="ml-1 text-[11px] text-ink-3">{m.common_opens_new_tab()}</span>
</a>
</div>
</div>

View File

@@ -0,0 +1,71 @@
import { describe, it, expect, afterEach, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import TranscribeCoachEmptyState from './TranscribeCoachEmptyState.svelte';
vi.mock('$lib/paraglide/messages.js', () => ({
m: {
transcribe_coach_title: () => 'Erste Transkription?',
transcribe_coach_preamble: () => 'Unser Kurrent-Erkenner lernt noch.',
transcribe_coach_step_1_title: () => 'Rahmen ziehen.',
transcribe_coach_step_1_body: () => 'Klicken und ziehen Sie mit der Maus einen Rahmen.',
transcribe_coach_step_2_title: () => 'Text eingeben.',
transcribe_coach_step_2_body: () => 'Geben Sie den Text ein.',
transcribe_coach_step_3_title: () => 'Speichert automatisch.',
transcribe_coach_footer_kurrent: () => 'Hilfe zu Kurrent ↗',
transcribe_coach_footer_richtlinien: () => 'Transkriptions-Richtlinien ↗',
common_opens_new_tab: () => '(öffnet in neuem Tab)'
}
}));
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
describe('TranscribeCoachEmptyState', () => {
it('renders the title and preamble', async () => {
render(TranscribeCoachEmptyState);
await expect
.element(page.getByRole('heading', { level: 2 }))
.toHaveTextContent('Erste Transkription?');
await expect.element(page.getByText('Unser Kurrent-Erkenner lernt noch.')).toBeInTheDocument();
});
it('renders three numbered steps', async () => {
render(TranscribeCoachEmptyState);
await expect.element(page.getByText('Rahmen ziehen.')).toBeInTheDocument();
await expect
.element(page.getByText('Klicken und ziehen Sie mit der Maus einen Rahmen.'))
.toBeInTheDocument();
await expect.element(page.getByText('Text eingeben.')).toBeInTheDocument();
await expect.element(page.getByText('Geben Sie den Text ein.')).toBeInTheDocument();
await expect.element(page.getByText('Speichert automatisch.')).toBeInTheDocument();
});
it('renders footer links to Wikipedia Kurrent and Richtlinien page', async () => {
render(TranscribeCoachEmptyState);
const kurrentLink = page.getByRole('link', { name: /Hilfe zu Kurrent/ });
await expect.element(kurrentLink).toBeInTheDocument();
await expect.element(kurrentLink).toHaveAttribute('target', '_blank');
await expect.element(kurrentLink).toHaveAttribute('rel', 'noopener noreferrer');
await expect.element(kurrentLink).toHaveAttribute('referrerpolicy', 'no-referrer');
const richtlinienLink = page.getByRole('link', { name: /Transkriptions-Richtlinien/ });
await expect.element(richtlinienLink).toBeInTheDocument();
await expect.element(richtlinienLink).toHaveAttribute('target', '_blank');
await expect.element(richtlinienLink).toHaveAttribute('rel', 'noopener noreferrer');
});
it('renders visible "(öffnet in neuem Tab)" annotation on each footer link', async () => {
render(TranscribeCoachEmptyState);
const annotations = page.getByText('(öffnet in neuem Tab)');
await expect.element(annotations.first()).toBeInTheDocument();
});
it('renders the drag demo animation region inside step 1', async () => {
render(TranscribeCoachEmptyState);
const demo = page.getByRole('img', { name: /Rahmen ziehen|Animation/i });
await expect.element(demo).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,217 @@
<script lang="ts">
// $derived from .matches is a one-shot snapshot — it doesn't react when the
// user toggles the OS setting at runtime. Use $state + addEventListener instead.
let prefersReducedMotion = $state(
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches
);
$effect(() => {
if (typeof window === 'undefined') return;
const mql = window.matchMedia('(prefers-reduced-motion: reduce)');
const handler = (e: MediaQueryListEvent) => {
prefersReducedMotion = e.matches;
};
mql.addEventListener('change', handler);
return () => mql.removeEventListener('change', handler);
});
</script>
{#if prefersReducedMotion}
<!-- Static final frame for reduced-motion users -->
<svg
role="img"
aria-label="Eine gestrichelte Umrandung markiert eine Zeile Kurrentschrift auf dem Dokument."
viewBox="0 0 600 180"
class="border-brand-sand block w-full rounded-sm border bg-parchment"
>
<g
stroke="#2a2a2a"
stroke-width="1.6"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M 70 100 q 4 -25 10 -8 q 6 22 14 -2 q 4 -20 10 -3 q 6 22 12 -6 q 4 -18 10 2" />
<path d="M 145 100 q 5 -28 14 -2 q 5 -20 12 -4 q 6 22 14 -6 q 4 -15 10 4" />
<path d="M 230 100 q 6 -26 14 -2 q 5 -22 12 1 q 5 25 14 -5 q 4 -18 10 3 q 5 -20 10 1" />
<path d="M 340 100 q 5 -30 12 -4 q 6 -18 14 5 q 5 -24 12 0 q 6 24 14 -10" />
<path d="M 440 100 q 6 -28 14 0 q 5 -22 12 -4 q 6 24 14 -1 q 4 -18 10 2" />
</g>
<line
x1="60"
y1="120"
x2="540"
y2="120"
stroke="#D4D1C4"
stroke-width="0.8"
stroke-dasharray="2 3"
/>
<rect
x="55"
y="68"
width="470"
height="57"
fill="rgba(166, 218, 216, 0.12)"
stroke="#002850"
stroke-width="2.2"
/>
<g transform="translate(515, 58)">
<circle cx="0" cy="0" r="9" fill="#002850" />
<path
d="M -4 0 L -1 3 L 4 -3"
stroke="white"
stroke-width="2"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
/>
</g>
</svg>
{:else}
<!-- Animated 5-second drawing loop -->
<svg
role="img"
aria-label="Animation: Ein Cursor zieht einen gestrichelten Rahmen um eine Zeile Kurrentschrift. Beim Loslassen wird der Rahmen durchgehend und ein Häkchen erscheint."
viewBox="0 0 600 180"
class="border-brand-sand block w-full rounded-sm border bg-parchment"
>
<!-- Kurrent writing (static) -->
<g
stroke="#2a2a2a"
stroke-width="1.6"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M 70 100 q 4 -25 10 -8 q 6 22 14 -2 q 4 -20 10 -3 q 6 22 12 -6 q 4 -18 10 2" />
<path d="M 145 100 q 5 -28 14 -2 q 5 -20 12 -4 q 6 22 14 -6 q 4 -15 10 4" />
<path d="M 230 100 q 6 -26 14 -2 q 5 -22 12 1 q 5 25 14 -5 q 4 -18 10 3 q 5 -20 10 1" />
<path d="M 340 100 q 5 -30 12 -4 q 6 -18 14 5 q 5 -24 12 0 q 6 24 14 -10" />
<path d="M 440 100 q 6 -28 14 0 q 5 -22 12 -4 q 6 24 14 -1 q 4 -18 10 2" />
</g>
<line
x1="60"
y1="120"
x2="540"
y2="120"
stroke="#D4D1C4"
stroke-width="0.8"
stroke-dasharray="2 3"
/>
<!-- Click ripple -->
<circle cx="55" cy="68" r="0" fill="none" stroke="#A6DAD8" stroke-width="2.5" opacity="0">
<animate
attributeName="r"
values="0;0;4;18;0;0"
keyTimes="0;0.17;0.19;0.24;0.26;1"
dur="5s"
repeatCount="indefinite"
/>
<animate
attributeName="opacity"
values="0;0;1;0;0;0"
keyTimes="0;0.17;0.19;0.24;0.26;1"
dur="5s"
repeatCount="indefinite"
/>
</circle>
<!-- Growing selection rectangle -->
<rect
x="55"
y="68"
width="0"
height="0"
fill="rgba(166, 218, 216, 0.12)"
stroke="#002850"
stroke-width="2"
stroke-dasharray="5 4"
opacity="0"
>
<animate
attributeName="opacity"
values="0;0;1;1;1;1;0;0"
keyTimes="0;0.18;0.20;0.88;0.92;0.94;0.98;1"
dur="5s"
repeatCount="indefinite"
/>
<animate
attributeName="width"
values="0;0;470;470;470;470;0"
keyTimes="0;0.20;0.62;0.88;0.94;0.98;1"
dur="5s"
repeatCount="indefinite"
/>
<animate
attributeName="height"
values="0;0;57;57;57;57;0"
keyTimes="0;0.20;0.62;0.88;0.94;0.98;1"
dur="5s"
repeatCount="indefinite"
/>
<animate
attributeName="stroke-dasharray"
values="5 4;5 4;5 4;1 0;1 0;5 4"
keyTimes="0;0.60;0.64;0.68;0.94;1"
dur="5s"
repeatCount="indefinite"
/>
<animate
attributeName="stroke-width"
values="2;2;2;3.2;2.2;2;2"
keyTimes="0;0.64;0.66;0.68;0.72;0.90;1"
dur="5s"
repeatCount="indefinite"
/>
</rect>
<!-- Confirmation checkmark badge -->
<g opacity="0" transform="translate(515, 58)">
<circle cx="0" cy="0" r="9" fill="#002850" />
<path
d="M -4 0 L -1 3 L 4 -3"
stroke="white"
stroke-width="2"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
/>
<animate
attributeName="opacity"
values="0;0;1;1;0;0"
keyTimes="0;0.66;0.70;0.86;0.92;1"
dur="5s"
repeatCount="indefinite"
/>
</g>
<!-- Cursor arrow -->
<g>
<animateTransform
attributeName="transform"
type="translate"
values="15,20; 55,68; 55,68; 525,125; 525,125; 15,20"
keyTimes="0; 0.15; 0.20; 0.62; 0.92; 1"
calcMode="spline"
keySplines="0.4 0 0.2 1; 0 0 1 1; 0.4 0 0.2 1; 0 0 1 1; 0.4 0 0.2 1"
dur="5s"
repeatCount="indefinite"
/>
<animate
attributeName="opacity"
values="1;1;1;0;0;1"
keyTimes="0;0.92;0.94;0.96;0.99;1"
dur="5s"
repeatCount="indefinite"
/>
<path
d="M 0 0 L 0 16 L 4.5 12 L 7.5 18 L 10.5 16.6 L 7.8 10.6 L 13 9 Z"
fill="#002850"
stroke="white"
stroke-width="0.8"
stroke-linejoin="round"
/>
</g>
</svg>
{/if}

View File

@@ -0,0 +1,23 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import TranscribeDragDemo from './TranscribeDragDemo.svelte';
afterEach(() => {
cleanup();
});
describe('TranscribeDragDemo', () => {
it('renders an SVG with an aria-label describing the animation', async () => {
render(TranscribeDragDemo);
const svg = page.getByRole('img');
await expect.element(svg).toBeInTheDocument();
await expect.element(svg).toHaveAttribute('aria-label');
});
it('contains a dashed-border rectangle animation element', async () => {
const { container } = render(TranscribeDragDemo);
const rect = container.querySelector('rect');
expect(rect).not.toBeNull();
});
});

View File

@@ -2,6 +2,7 @@
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import TranscriptionBlock from './TranscriptionBlock.svelte'; import TranscriptionBlock from './TranscriptionBlock.svelte';
import OcrTrigger from './OcrTrigger.svelte'; import OcrTrigger from './OcrTrigger.svelte';
import TranscribeCoachEmptyState from './TranscribeCoachEmptyState.svelte';
import type { TranscriptionBlockData } from '$lib/types'; import type { TranscriptionBlockData } from '$lib/types';
import { createBlockAutoSave } from '$lib/hooks/useBlockAutoSave.svelte'; import { createBlockAutoSave } from '$lib/hooks/useBlockAutoSave.svelte';
import { createBlockDragDrop } from '$lib/hooks/useBlockDragDrop.svelte'; import { createBlockDragDrop } from '$lib/hooks/useBlockDragDrop.svelte';
@@ -231,28 +232,12 @@ async function handleLabelToggle(label: string) {
</div> </div>
</div> </div>
{:else} {:else}
<div class="flex flex-1 flex-col items-center justify-center px-6 py-12 text-center"> <div class="p-4">
<svg <TranscribeCoachEmptyState />
class="mb-4 h-16 w-16 text-ink-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
/>
</svg>
<p class="max-w-xs text-sm leading-relaxed text-ink-3">
{m.transcription_empty_draw_hint()}
</p>
</div> </div>
{/if} {/if}
{#if canWrite} {#if canWrite && hasBlocks}
<div class="border-t border-line px-4 py-3"> <div class="border-t border-line px-4 py-3">
<p class="mb-2 font-sans text-xs font-medium text-ink-2">Für Training vormerken</p> <p class="mb-2 font-sans text-xs font-medium text-ink-2">Für Training vormerken</p>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">

View File

@@ -61,9 +61,21 @@ describe('TranscriptionEditView — rendering', () => {
await expect.element(page.getByText(/Block 3/)).toBeInTheDocument(); await expect.element(page.getByText(/Block 3/)).toBeInTheDocument();
}); });
it('shows empty state when no blocks', async () => { it('shows coach card when no blocks', async () => {
renderView({ blocks: [] }); renderView({ blocks: [] });
await expect.element(page.getByText(/Zeichnen Sie Bereiche/)).toBeInTheDocument(); await expect
.element(page.getByRole('heading', { level: 2 }))
.toHaveTextContent('Erste Transkription?');
});
it('hides training footer when no blocks', async () => {
renderView({ blocks: [], canWrite: true });
await expect.element(page.getByText('Für Training vormerken')).not.toBeInTheDocument();
});
it('shows training footer when blocks exist', async () => {
renderView({ blocks: [block1], canWrite: true });
await expect.element(page.getByText('Für Training vormerken')).toBeInTheDocument();
}); });
}); });

View File

@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import { getLocale } from '$lib/paraglide/runtime.js'; import { getLocale } from '$lib/paraglide/runtime.js';
import HelpPopover from './HelpPopover.svelte';
type Props = { type Props = {
mode: 'read' | 'edit'; mode: 'read' | 'edit';
@@ -33,31 +34,36 @@ function handleReadClick() {
<div <div
class="flex h-[44px] items-center justify-between border-b border-line bg-surface px-3 font-sans" class="flex h-[44px] items-center justify-between border-b border-line bg-surface px-3 font-sans"
> >
<!-- Segmented toggle --> <!-- Segmented toggle + help chip -->
<div class="flex h-9 items-center rounded-full border border-line bg-muted p-0.5 md:h-7"> <div class="flex items-center gap-1.5">
<button <div class="flex h-9 items-center rounded-full border border-line bg-muted p-0.5 md:h-7">
type="button" <button
data-testid="mode-read" type="button"
aria-disabled={!hasBlocks} data-testid="mode-read"
onclick={handleReadClick} aria-disabled={!hasBlocks}
class="h-full rounded-full px-3 text-xs font-semibold transition-colors {mode === 'read' onclick={handleReadClick}
class="h-full rounded-full px-3 text-xs font-semibold transition-colors {mode === 'read'
? 'bg-primary text-primary-fg' ? 'bg-primary text-primary-fg'
: 'text-ink-2 hover:text-ink'}" : 'text-ink-2 hover:text-ink'}"
style:opacity={!hasBlocks ? '0.35' : undefined} style:opacity={!hasBlocks ? '0.35' : undefined}
> >
{m.mode_read()} {m.mode_read()}
</button> </button>
<button <button
type="button" type="button"
data-testid="mode-edit" data-testid="mode-edit"
onclick={() => onModeChange('edit')} onclick={() => onModeChange('edit')}
class="h-full rounded-full px-3 text-xs font-semibold transition-colors {mode === 'edit' class="h-full rounded-full px-3 text-xs font-semibold transition-colors {mode === 'edit'
? 'bg-primary text-primary-fg' ? 'bg-primary text-primary-fg'
: 'text-ink-2 hover:text-ink'}" : 'text-ink-2 hover:text-ink'}"
> >
<span class="md:hidden">{m.mode_edit_short()}</span> <span class="md:hidden">{m.mode_edit_short()}</span>
<span class="hidden md:inline">{m.mode_edit()}</span> <span class="hidden md:inline">{m.mode_edit()}</span>
</button> </button>
</div>
<HelpPopover label={m.transcription_mode_help_label()}>
<p class="text-xs leading-relaxed">{m.transcription_mode_help_body()}</p>
</HelpPopover>
</div> </div>
<!-- Status line (hidden on mobile to save space) --> <!-- Status line (hidden on mobile to save space) -->

View File

@@ -1,8 +1,10 @@
import { describe, it, expect, vi } from 'vitest'; import { describe, it, expect, vi, afterEach } from 'vitest';
import { render } from 'vitest-browser-svelte'; import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser'; import { page } from 'vitest/browser';
import TranscriptionPanelHeader from './TranscriptionPanelHeader.svelte'; import TranscriptionPanelHeader from './TranscriptionPanelHeader.svelte';
afterEach(cleanup);
describe('TranscriptionPanelHeader', () => { describe('TranscriptionPanelHeader', () => {
it('should render Lesen and Bearbeiten buttons', async () => { it('should render Lesen and Bearbeiten buttons', async () => {
render(TranscriptionPanelHeader, { render(TranscriptionPanelHeader, {
@@ -148,4 +150,33 @@ describe('TranscriptionPanelHeader', () => {
expect(statusText).not.toBeNull(); expect(statusText).not.toBeNull();
expect(statusText!.textContent).toContain('2026'); expect(statusText!.textContent).toContain('2026');
}); });
it('renders a (?) help chip next to the Read/Edit toggle', async () => {
render(TranscriptionPanelHeader, {
mode: 'read',
hasBlocks: true,
blockCount: 3,
lastEditedAt: null,
onModeChange: () => {},
onClose: () => {}
});
const helpBtn = document.querySelector('button[aria-expanded]') as HTMLButtonElement;
expect(helpBtn).not.toBeNull();
});
it('opens a help popover with mode explanation when the chip is clicked', async () => {
render(TranscriptionPanelHeader, {
mode: 'read',
hasBlocks: true,
blockCount: 3,
lastEditedAt: null,
onModeChange: () => {},
onClose: () => {}
});
const helpBtn = document.querySelector('button[aria-expanded]') as HTMLButtonElement;
helpBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await vi.waitFor(() => expect(document.querySelector('[role="region"]')).not.toBeNull());
});
}); });

View File

@@ -0,0 +1,539 @@
<script lang="ts">
import { SvelteMap } from 'svelte/reactivity';
import { goto } from '$app/navigation';
import { onDestroy, onMount, untrack } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
import { getConfirmService } from '$lib/services/confirm.svelte.js';
import type { ConfirmService } from '$lib/services/confirm.svelte.js';
import { bulkSelectionStore } from '$lib/stores/bulkSelection.svelte';
import BulkDropZone from './BulkDropZone.svelte';
import FileSwitcherStrip from './FileSwitcherStrip.svelte';
import type { FileEntry } from './FileSwitcherStrip.svelte';
import ScopeCard from './ScopeCard.svelte';
import UploadSaveBar from './UploadSaveBar.svelte';
import WhoWhenSection from './WhoWhenSection.svelte';
import DescriptionSection from './DescriptionSection.svelte';
import PdfViewer from '$lib/components/PdfViewer.svelte';
import { bulkTitleFromFilename } from '$lib/utils/filename';
import type { Tag } from '$lib/components/TagInput.svelte';
import type { components } from '$lib/generated/api';
type Person = components['schemas']['Person'];
// Mirrors the backend `DocumentBatchSummary` JSON shape one-to-one — the route
// passes the parsed `/api/documents/batch-metadata` response straight in, so
// the field names must match what the backend actually serializes (id, not
// documentId). The FileEntry built from each summary still uses both `id` and
// `documentId` so the save handler can drive the PATCH payload by UUID.
export type BulkEditEntry = {
id: string;
title: string;
pdfUrl: string;
};
// Optional — not available in unit tests that don't provide CONFIRM_KEY context.
let _confirmService: ConfirmService | null;
try {
_confirmService = getConfirmService();
} catch {
_confirmService = null;
}
let {
mode = 'upload',
initialSenderId = '',
initialSenderName = '',
initialReceivers = [],
initialEditEntries = []
}: {
mode?: 'upload' | 'edit';
initialSenderId?: string;
initialSenderName?: string;
initialReceivers?: Person[];
initialEditEntries?: BulkEditEntry[];
} = $props();
// --- File state ---
let files = new SvelteMap<string, FileEntry>();
let activeId = $state<string | null>(null);
let chunkProgress = $state<{ done: number; total: number } | undefined>(undefined);
let saving = $state(false);
// Partial-failure surface: when set, the last save aborted at chunk N of M.
let partialSaved = $state<{ done: number; total: number } | null>(null);
// --- Shared metadata ---
let senderId = $state(untrack(() => initialSenderId));
let selectedReceivers = $state<Person[]>(untrack(() => initialReceivers));
let dateIso = $state('');
let tags = $state<Tag[]>([]);
// Bulk-edit only — replace-on-non-blank semantics.
let documentLocation = $state('');
let archiveBox = $state('');
let archiveFolder = $state('');
// Hydrate edit-mode entries on mount. The IDs in bulkSelectionStore drive the
// fetch upstream in the route — by the time this layout mounts, the metadata
// has already been resolved into `initialEditEntries`. Wrapped in onMount so
// the SvelteMap mutation is unambiguously tied to instance lifecycle, not to
// the script body's first execution (Felix C4 cycle 3).
onMount(() => {
if (mode !== 'edit') return;
for (const entry of untrack(() => initialEditEntries)) {
files.set(entry.id, {
id: entry.id,
documentId: entry.id,
title: entry.title,
status: 'idle',
previewUrl: entry.pdfUrl
});
if (!activeId) activeId = entry.id;
}
});
// --- Derived ---
const isMulti = $derived(files.size >= 2);
const activeFile = $derived(activeId ? files.get(activeId) : null);
// --- File management ---
function addFiles(newFiles: File[]) {
for (const file of newFiles) {
const id = crypto.randomUUID();
const title = bulkTitleFromFilename(file.name);
const previewUrl = URL.createObjectURL(file);
files.set(id, { id, file, title, status: 'idle', previewUrl });
if (!activeId) activeId = id;
}
}
function removeFile(id: string) {
const entry = files.get(id);
if (entry?.previewUrl) URL.revokeObjectURL(entry.previewUrl);
files.delete(id);
if (activeId === id) {
activeId = files.keys().next().value ?? null;
}
}
function setTitle(id: string, title: string) {
const entry = files.get(id);
if (entry) files.set(id, { ...entry, title });
}
function discardAll() {
for (const entry of files.values()) {
if (entry.previewUrl) URL.revokeObjectURL(entry.previewUrl);
}
files.clear();
activeId = null;
chunkProgress = undefined;
}
async function handleDiscard() {
if (_confirmService) {
const ok = await _confirmService.confirm({
title: m.bulk_discard_all(),
body: m.bulk_discard_confirm(),
destructive: true
});
if (!ok) return;
}
if (mode === 'edit') {
// In edit mode the file map IS the user's bulk selection — discarding
// must clear the upstream store and bounce back to the list, otherwise
// the user is left on /documents/bulk-edit with an empty form and a
// stale count in the bottom bar (issue #225 Bulk-Edit Panel table).
bulkSelectionStore.clear();
discardAll();
await goto('/documents');
return;
}
discardAll();
}
onDestroy(() => {
for (const entry of files.values()) {
if (entry.previewUrl) URL.revokeObjectURL(entry.previewUrl);
}
});
// --- Save (upload mode) ---
async function saveUpload() {
const entries = Array.from(files.values());
// 10 files per request keeps multipart bodies well under typical reverse-proxy limits (e.g. nginx default 1 MB client_max_body_size per PDF).
const chunkSize = 10;
const chunks: FileEntry[][] = [];
for (let i = 0; i < entries.length; i += chunkSize) {
chunks.push(entries.slice(i, i + chunkSize));
}
chunkProgress = { done: 0, total: chunks.length };
let hadErrors = false;
for (let i = 0; i < chunks.length; i++) {
const chunk = chunks[i];
const formData = new FormData();
chunk.forEach((entry) => entry.file && formData.append('files', entry.file));
const metadata = {
titles: chunk.map((e) => e.title),
senderId: senderId || null,
receiverIds: selectedReceivers.map((r) => r.id),
documentDate: dateIso || null,
tagNames: tags.map((t) => t.name)
};
formData.append('metadata', new Blob([JSON.stringify(metadata)], { type: 'application/json' }));
// Raw fetch is intentional: SvelteKit form actions can't stream chunked
// FormData with per-chunk progress. Session cookie is sent automatically
// by the browser for same-origin requests.
try {
const res = await fetch('/api/documents/quick-upload', { method: 'POST', body: formData });
const body = await res.json().catch(() => ({ errors: [] }));
const errorFilenames = new Set<string>(
(body.errors ?? []).map((err: { filename: string }) => err.filename)
);
if (!res.ok || errorFilenames.size > 0) {
hadErrors = true;
for (const entry of chunk) {
const filename = entry.file?.name;
const isError = errorFilenames.size > 0 && filename ? errorFilenames.has(filename) : true;
if (isError) {
const e = files.get(entry.id);
if (e) files.set(entry.id, { ...e, status: 'error' });
}
}
}
} catch {
hadErrors = true;
for (const entry of chunk) {
const e = files.get(entry.id);
if (e) files.set(entry.id, { ...e, status: 'error' });
}
}
chunkProgress = { done: i + 1, total: chunks.length };
}
if (!hadErrors) goto('/documents');
}
// --- Save (edit mode) ---
async function saveBulkEdit() {
const entries = Array.from(files.values());
const ids = entries.map((e) => e.documentId).filter((x): x is string => !!x);
// PATCH cap matches backend: 500 IDs per request. Sequential, stop on chunk
// failure so the user sees a deterministic "X of N saved" outcome.
const chunkSize = 500;
const chunks: string[][] = [];
for (let i = 0; i < ids.length; i += chunkSize) {
chunks.push(ids.slice(i, i + chunkSize));
}
chunkProgress = { done: 0, total: chunks.length };
partialSaved = null;
const dto = {
tagNames: tags.map((t) => t.name),
senderId: senderId || null,
receiverIds: selectedReceivers.map((r) => r.id),
documentLocation: documentLocation || null,
archiveBox: archiveBox || null,
archiveFolder: archiveFolder || null
};
for (let i = 0; i < chunks.length; i++) {
const chunk = chunks[i];
try {
const res = await fetch('/api/documents/bulk', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...dto, documentIds: chunk })
});
if (!res.ok) {
// Network/server failure: the chunk did not apply. Mark its entries
// as errored, surface partial-save state, and stop.
for (const id of chunk) {
const e = files.get(id);
if (e) files.set(id, { ...e, status: 'error' });
}
partialSaved = { done: i, total: chunks.length };
return;
}
const body = (await res.json().catch(() => null)) as {
updated: number;
errors: { id: string; message: string }[];
} | null;
if (body && body.errors && body.errors.length > 0) {
for (const err of body.errors) {
const e = files.get(err.id);
if (e) files.set(err.id, { ...e, status: 'error' });
}
}
} catch {
for (const id of chunk) {
const e = files.get(id);
if (e) files.set(id, { ...e, status: 'error' });
}
partialSaved = { done: i, total: chunks.length };
return;
}
chunkProgress = { done: i + 1, total: chunks.length };
}
const stillErrored = Array.from(files.values()).some((e) => e.status === 'error');
if (!stillErrored) {
bulkSelectionStore.clear();
goto('/documents');
}
}
async function save() {
if (saving) return;
saving = true;
try {
if (mode === 'edit') {
await saveBulkEdit();
} else {
await saveUpload();
}
} finally {
saving = false;
}
}
async function retrySave() {
partialSaved = null;
await save();
}
</script>
<div class="fixed inset-x-0 bottom-0 flex flex-col" style="top: var(--header-height)">
<!-- Topbar -->
<div class="flex shrink-0 items-center gap-3 border-b border-line bg-surface px-6 py-3">
<a
href="/documents"
class="flex items-center gap-1.5 text-xs font-bold tracking-widest text-ink-3 uppercase hover:text-ink"
>
<svg
class="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 19l-7-7m0 0l7-7m-7 7h18"
/>
</svg>
{m.btn_back_to_overview()}
</a>
<span class="text-ink-3" aria-hidden="true">·</span>
<span class="font-serif text-sm font-bold text-ink">
{#if mode === 'edit'}
{m.bulk_edit_topbar_title()}
{:else}
{isMulti ? m.bulk_title_multi() : m.bulk_title_single()}
{/if}
</span>
{#if isMulti}
<span class="ml-auto flex items-center gap-3">
<span class="rounded-[2px] bg-accent px-2 py-0.5 text-xs font-bold text-primary">
{#if mode === 'edit'}
{m.bulk_edit_count_pill({ count: files.size })}
{:else}
{m.bulk_count_pill({ count: files.size })}
{/if}
</span>
<button
type="button"
data-testid="discard-all-btn"
onclick={handleDiscard}
class="text-xs font-medium text-red-600/70 hover:text-red-700"
>
{m.bulk_discard_all()}
</button>
</span>
{/if}
</div>
<!-- Split panel -->
<div class="flex flex-1 overflow-hidden">
<!-- Left: PDF preview / drop zone (55%) -->
<div class="relative flex flex-[55] flex-col overflow-hidden border-r border-line bg-pdf-bg">
{#if mode === 'upload' && files.size === 0}
<!-- N=0: centred drop-zone box fills the panel (upload only) -->
<BulkDropZone onFilesAdded={addFiles} />
{:else if files.size > 0}
<!-- PDF preview: blob URL in upload mode, server URL in edit mode -->
<div class="relative flex-1 overflow-hidden">
{#if activeFile}
<PdfViewer url={activeFile.previewUrl} />
{/if}
</div>
{#if isMulti}
<!-- File switcher strip pinned to bottom of left panel -->
<FileSwitcherStrip
files={Array.from(files.values())}
activeId={activeId ?? ''}
onSelect={(id) => (activeId = id)}
onRemove={removeFile}
/>
{/if}
{/if}
</div>
<!-- Right: metadata form (45%) -->
<div class="flex flex-[45] flex-col overflow-hidden">
<!-- Scrollable form area — greyed out and non-interactive when no files selected -->
<div
class="flex-1 space-y-4 overflow-y-auto p-4 transition-opacity"
class:opacity-60={files.size === 0}
class:pointer-events-none={files.size === 0}
>
{#if mode === 'edit'}
<!-- Onboarding callout: tells the user that empty fields are skipped
and that tags/receivers are added rather than replaced.
No aria-label — role=note + the visible text content is
self-describing; an aria-label would override that text for
AT users on non-DE locales. -->
<div
role="note"
data-testid="bulk-edit-callout"
class="rounded-sm border border-accent/40 bg-accent/15 px-4 py-3 text-sm text-ink-2"
>
{m.bulk_edit_hint()}
</div>
{/if}
{#if isMulti}
<!-- N≥2: per-file card (title) + shared card (metadata) -->
<ScopeCard variant="per-file">
{#if activeFile}
{#if mode === 'edit'}
<div data-testid="readonly-title">
<span class="mb-1 block text-xs font-medium tracking-widest text-ink-2 uppercase">
{m.form_label_title()}
</span>
<p class="font-serif text-base text-ink">{activeFile.title}</p>
</div>
{:else}
<label class="block">
<span class="mb-1 block text-xs font-medium tracking-widest text-ink-2 uppercase">
{m.form_label_title()} <span class="text-danger">*</span>
</span>
<input
type="text"
value={activeFile.title}
oninput={(e) =>
setTitle(activeId!, (e.currentTarget as HTMLInputElement).value)}
class="block w-full rounded-sm border border-line bg-surface p-2 text-sm focus:border-accent focus:outline-none"
/>
</label>
{/if}
{/if}
</ScopeCard>
<ScopeCard variant="shared" count={files.size}>
<WhoWhenSection
bind:senderId={senderId}
bind:selectedReceivers={selectedReceivers}
bind:dateIso={dateIso}
initialSenderName={initialSenderName}
hideDate={mode === 'edit'}
editMode={mode === 'edit'}
/>
<DescriptionSection
bind:tags={tags}
bind:documentLocation={documentLocation}
bind:archiveBox={archiveBox}
bind:archiveFolder={archiveFolder}
hideTitle
editMode={mode === 'edit'}
/>
</ScopeCard>
{:else}
<!-- N=0 (disabled placeholder) or N=1 (active): title + shared form -->
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
{#if mode === 'edit' && activeFile}
<div data-testid="readonly-title">
<span class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.form_label_title()}
</span>
<p class="font-serif text-base text-ink">{activeFile.title}</p>
</div>
{:else}
<label class="block">
<span class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.form_label_title()} <span class="text-danger">*</span>
</span>
{#if activeFile}
<input
type="text"
value={activeFile.title}
oninput={(e) =>
setTitle(activeId!, (e.currentTarget as HTMLInputElement).value)}
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
{:else}
<input
type="text"
disabled
placeholder="—"
class="block w-full rounded border border-line p-2 text-sm text-ink-3 shadow-sm"
/>
{/if}
</label>
{/if}
</div>
<WhoWhenSection
bind:senderId={senderId}
bind:selectedReceivers={selectedReceivers}
bind:dateIso={dateIso}
initialSenderName={initialSenderName}
hideDate={mode === 'edit'}
editMode={mode === 'edit'}
/>
<DescriptionSection
bind:tags={tags}
bind:documentLocation={documentLocation}
bind:archiveBox={archiveBox}
bind:archiveFolder={archiveFolder}
hideTitle
editMode={mode === 'edit'}
/>
{/if}
{#if partialSaved}
<div
role="alert"
data-testid="bulk-edit-partial-failure"
class="rounded-sm border border-danger/40 bg-danger/10 px-4 py-3 text-sm text-danger"
>
<p class="font-medium">
{m.bulk_edit_save_partial({
done: partialSaved.done,
total: partialSaved.total
})}
</p>
<button
type="button"
onclick={retrySave}
class="mt-2 inline-flex items-center bg-primary px-4 py-2 text-xs font-bold tracking-widest text-primary-fg uppercase transition-colors hover:bg-primary/90"
>
{m.bulk_edit_retry()}
</button>
</div>
{/if}
</div>
<!-- Action bar: always visible at bottom of right panel -->
<UploadSaveBar
fileCount={files.size}
chunkProgress={chunkProgress}
onSave={save}
onDiscard={handleDiscard}
disabled={saving}
editMode={mode === 'edit'}
/>
</div>
</div>
</div>

View File

@@ -0,0 +1,543 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { goto } from '$app/navigation';
import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser';
import BulkDocumentEditLayout from './BulkDocumentEditLayout.svelte';
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.clearAllMocks();
});
function makeFile(name: string): File {
return new File(['content'], name, { type: 'application/pdf' });
}
async function addFilesViaInput(container: HTMLElement, files: File[]): Promise<void> {
const input = container.querySelector('input[type="file"]') as HTMLInputElement;
if (!input) throw new Error('No file input found — is BulkDropZone visible?');
await userEvent.upload(input, files);
}
describe('BulkDocumentEditLayout', () => {
it('N=0: shows BulkDropZone', async () => {
render(BulkDocumentEditLayout, {});
await expect.element(page.getByTestId('bulk-drop-zone')).toBeInTheDocument();
});
it('N=1: file-switcher-strip and per-file scope card are absent', async () => {
const { container } = render(BulkDocumentEditLayout, {});
await addFilesViaInput(container, [makeFile('doc.pdf')]);
expect(container.querySelector('[data-testid="file-switcher-strip"]')).toBeNull();
expect(container.querySelector('[data-variant="per-file"]')).toBeNull();
});
it('N=5: file-switcher-strip and per-file scope card are both present', async () => {
const { container } = render(BulkDocumentEditLayout, {});
await addFilesViaInput(container, [
makeFile('a.pdf'),
makeFile('b.pdf'),
makeFile('c.pdf'),
makeFile('d.pdf'),
makeFile('e.pdf')
]);
expect(container.querySelector('[data-testid="file-switcher-strip"]')).not.toBeNull();
expect(container.querySelector('[data-variant="per-file"]')).not.toBeNull();
});
it('removing middle file preserves order of remaining files', async () => {
const { container } = render(BulkDocumentEditLayout, {});
await addFilesViaInput(container, [
makeFile('file0.pdf'),
makeFile('file1.pdf'),
makeFile('file2.pdf')
]);
// Remove the chip for file1 via its remove button (identified by data-remove-id)
const removeButtons = container.querySelectorAll<HTMLButtonElement>(
'[data-testid="file-switcher-strip"] button[data-remove-id]'
);
expect(removeButtons.length).toBe(3);
removeButtons[1].click(); // remove file1
// Wait for Svelte to flush the DOM update
await vi.waitFor(
() => {
const chips = container.querySelectorAll(
'[data-testid="file-switcher-strip"] [data-chip-id]'
);
expect(chips.length).toBe(2);
expect(chips[0].textContent?.trim()).toContain('file0');
expect(chips[1].textContent?.trim()).toContain('file2');
},
{ timeout: 1000 }
);
});
it('save calls fetch twice for 12 files (2 chunks of 10)', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ created: [], updated: [], errors: [] })
});
vi.stubGlobal('fetch', mockFetch);
const { container } = render(BulkDocumentEditLayout, {});
const files = Array.from({ length: 12 }, (_, i) => makeFile(`f${i}.pdf`));
await addFilesViaInput(container, files);
const saveBtn = container.querySelector(
'button[data-testid="bulk-save-btn"]'
) as HTMLButtonElement;
expect(saveBtn).not.toBeNull();
saveBtn.click();
// Wait for async save to complete
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(2), { timeout: 3000 });
});
it('save marks file as error when server returns non-ok response', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: false,
json: async () => ({ errors: [{ filename: 'f0.pdf', code: 'FILE_UPLOAD_FAILED' }] })
});
vi.stubGlobal('fetch', mockFetch);
const { container } = render(BulkDocumentEditLayout, {});
await addFilesViaInput(container, [makeFile('f0.pdf')]);
const saveBtn = container.querySelector(
'button[data-testid="bulk-save-btn"]'
) as HTMLButtonElement;
saveBtn.click();
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1), { timeout: 3000 });
});
it('save() includes tagNames in metadata payload', async () => {
let capturedFormData: FormData | undefined;
const mockFetch = vi.fn().mockImplementation(async (_url: string, init: RequestInit) => {
capturedFormData = init?.body as FormData;
return { ok: true, json: async () => ({ created: [], updated: [], errors: [] }) };
});
vi.stubGlobal('fetch', mockFetch);
const { container } = render(BulkDocumentEditLayout, {});
await addFilesViaInput(container, [makeFile('doc.pdf')]);
const saveBtn = container.querySelector(
'button[data-testid="bulk-save-btn"]'
) as HTMLButtonElement;
saveBtn.click();
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1), { timeout: 3000 });
expect(capturedFormData).toBeDefined();
const metadataBlob = capturedFormData!.get('metadata') as Blob;
const metadataJson = JSON.parse(await metadataBlob.text());
expect(metadataJson).toHaveProperty('tagNames');
});
it('save() navigates to /documents when all chunks succeed', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ created: [], updated: [], errors: [] })
});
vi.stubGlobal('fetch', mockFetch);
const { container } = render(BulkDocumentEditLayout, {});
await addFilesViaInput(container, [makeFile('doc.pdf')]);
const saveBtn = container.querySelector(
'button[data-testid="bulk-save-btn"]'
) as HTMLButtonElement;
saveBtn.click();
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1), { timeout: 3000 });
await vi.waitFor(() => expect(goto).toHaveBeenCalledWith('/documents'), { timeout: 3000 });
});
it('save() does not navigate when chunk returns non-ok response', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: false,
json: async () => ({ errors: [{ filename: 'f0.pdf', code: 'FILE_UPLOAD_FAILED' }] })
});
vi.stubGlobal('fetch', mockFetch);
const { container } = render(BulkDocumentEditLayout, {});
await addFilesViaInput(container, [makeFile('f0.pdf')]);
const saveBtn = container.querySelector(
'button[data-testid="bulk-save-btn"]'
) as HTMLButtonElement;
saveBtn.click();
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1), { timeout: 3000 });
expect(goto).not.toHaveBeenCalled();
});
it('save marks only the file whose filename matches the backend error, not adjacent files', async () => {
// backend returns error keyed to b.pdf — only b.pdf chip should get data-status="error"
const mockFetch = vi.fn().mockResolvedValue({
ok: false,
json: async () => ({ errors: [{ filename: 'b.pdf', code: 'FILE_UPLOAD_FAILED' }] })
});
vi.stubGlobal('fetch', mockFetch);
const { container } = render(BulkDocumentEditLayout, {});
await addFilesViaInput(container, [makeFile('a.pdf'), makeFile('b.pdf'), makeFile('c.pdf')]);
const saveBtn = container.querySelector(
'button[data-testid="bulk-save-btn"]'
) as HTMLButtonElement;
saveBtn.click();
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1), { timeout: 3000 });
await vi.waitFor(
() => {
const errorChips = container.querySelectorAll('[data-chip-id][data-status="error"]');
expect(errorChips.length).toBe(1);
expect(errorChips[0].textContent).toContain('b');
},
{ timeout: 1000 }
);
});
it('save() marks only the failed file when server returns HTTP 200 with a partial errors array', async () => {
// Backend can return 200 OK while reporting individual file failures
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
created: [{ id: '1' }],
updated: [],
errors: [{ filename: 'b.pdf', code: 'FILE_UPLOAD_FAILED' }]
})
});
vi.stubGlobal('fetch', mockFetch);
const { container } = render(BulkDocumentEditLayout, {});
await addFilesViaInput(container, [makeFile('a.pdf'), makeFile('b.pdf'), makeFile('c.pdf')]);
const saveBtn = container.querySelector(
'button[data-testid="bulk-save-btn"]'
) as HTMLButtonElement;
saveBtn.click();
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1), { timeout: 3000 });
await vi.waitFor(
() => {
const errorChips = container.querySelectorAll('[data-chip-id][data-status="error"]');
expect(errorChips.length).toBe(1);
expect(errorChips[0].textContent).toContain('b');
},
{ timeout: 1000 }
);
// Navigation should be suppressed because hadErrors is true
expect(goto).not.toHaveBeenCalled();
});
it('save() marks all chunk files as errored when fetch throws a network error', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network error')));
const { container } = render(BulkDocumentEditLayout, {});
await addFilesViaInput(container, [makeFile('a.pdf'), makeFile('b.pdf')]);
const saveBtn = container.querySelector(
'button[data-testid="bulk-save-btn"]'
) as HTMLButtonElement;
saveBtn.click();
await vi.waitFor(
() => {
const errorChips = container.querySelectorAll('[data-chip-id][data-status="error"]');
expect(errorChips.length).toBe(2);
},
{ timeout: 3000 }
);
expect(goto).not.toHaveBeenCalled();
});
it('save() does not call fetch a second time when already saving', async () => {
let resolveFirst: (() => void) | undefined;
const mockFetch = vi.fn().mockImplementation(
() =>
new Promise<Response>((resolve) => {
resolveFirst = () =>
resolve({
ok: true,
json: async () => ({ created: [], updated: [], errors: [] })
} as Response);
})
);
vi.stubGlobal('fetch', mockFetch);
const { container } = render(BulkDocumentEditLayout, {});
await addFilesViaInput(container, [makeFile('a.pdf')]);
const saveBtn = container.querySelector(
'button[data-testid="bulk-save-btn"]'
) as HTMLButtonElement;
saveBtn.click(); // first click — fetch is in-flight
saveBtn.click(); // second click — should be a no-op
resolveFirst?.();
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1), { timeout: 3000 });
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it('discard-all resets to N=0 state and shows drop zone', async () => {
const { container } = render(BulkDocumentEditLayout, {});
await addFilesViaInput(container, [makeFile('a.pdf'), makeFile('b.pdf')]);
// Confirm N=2 state — switcher is visible
expect(container.querySelector('[data-testid="file-switcher-strip"]')).not.toBeNull();
// Click the topbar discard-all button (only visible in isMulti state)
const discardBtn = container.querySelector(
'button[data-testid="discard-all-btn"]'
) as HTMLButtonElement;
expect(discardBtn).not.toBeNull();
discardBtn.click();
await vi.waitFor(
() => {
expect(container.querySelector('[data-testid="bulk-drop-zone"]')).not.toBeNull();
expect(container.querySelector('[data-testid="file-switcher-strip"]')).toBeNull();
},
{ timeout: 1000 }
);
});
});
// ─── mode="edit" ─────────────────────────────────────────────────────────────
describe('BulkDocumentEditLayout — mode="edit" discard', () => {
it('discard in edit mode clears the selection store and navigates back to /documents', async () => {
const { bulkSelectionStore } = await import('$lib/stores/bulkSelection.svelte');
bulkSelectionStore.setAll(['doc-1']);
const { container } = render(BulkDocumentEditLayout, {
mode: 'edit',
initialEditEntries: [
{ id: 'doc-1', title: 'Brief 1', pdfUrl: '/api/documents/doc-1/file' },
{ id: 'doc-2', title: 'Brief 2', pdfUrl: '/api/documents/doc-2/file' }
]
});
const discardBtn = container.querySelector(
'button[data-testid="discard-all-btn"]'
) as HTMLButtonElement;
expect(discardBtn).not.toBeNull();
discardBtn.click();
await vi.waitFor(() => expect(goto).toHaveBeenCalledWith('/documents'), { timeout: 1000 });
expect(bulkSelectionStore.size).toBe(0);
});
});
describe('BulkDocumentEditLayout — mode="edit"', () => {
const editEntry = (i: number) => ({
id: `doc-${i}`,
title: `Brief ${i}`,
pdfUrl: `/api/documents/doc-${i}/file`
});
it('does not render the BulkDropZone in edit mode', async () => {
const { container } = render(BulkDocumentEditLayout, {
mode: 'edit',
initialEditEntries: [editEntry(1)]
});
expect(container.querySelector('[data-testid="bulk-drop-zone"]')).toBeNull();
});
it('renders the onboarding callout with role=note in edit mode', async () => {
render(BulkDocumentEditLayout, {
mode: 'edit',
initialEditEntries: [editEntry(1)]
});
const callout = page.getByTestId('bulk-edit-callout');
await expect.element(callout).toBeInTheDocument();
await expect.element(callout).toHaveAttribute('role', 'note');
});
it('renders read-only title display (no input) in edit mode', async () => {
const { container } = render(BulkDocumentEditLayout, {
mode: 'edit',
initialEditEntries: [editEntry(1)]
});
expect(container.querySelector('[data-testid="readonly-title"]')).not.toBeNull();
// Per-file ScopeCard absent at N=1 — title rendered in the single card
const titleInput = container.querySelector('input[type="text"][value="Brief 1"]');
expect(titleInput).toBeNull();
});
it('hides the date field via WhoWhenSection hideDate prop', async () => {
const { container } = render(BulkDocumentEditLayout, {
mode: 'edit',
initialEditEntries: [editEntry(1)]
});
expect(container.querySelector('[data-testid="who-when-date"]')).toBeNull();
});
it('shows additive badge next to tags label', async () => {
const { container } = render(BulkDocumentEditLayout, {
mode: 'edit',
initialEditEntries: [editEntry(1)]
});
expect(container.querySelector('[data-testid="field-label-badge-additive"]')).not.toBeNull();
});
it('shows replace badges next to sender and archive fields', async () => {
const { container } = render(BulkDocumentEditLayout, {
mode: 'edit',
initialEditEntries: [editEntry(1)]
});
const replaceBadges = container.querySelectorAll('[data-testid="field-label-badge-replace"]');
// sender + documentLocation + archiveBox + archiveFolder = 4
expect(replaceBadges.length).toBeGreaterThanOrEqual(4);
});
it('topbar reads "Massenbearbeitung" + "{count} werden bearbeitet" in edit mode', async () => {
// Elicit C1 fix — upload-flavoured "Mehrere Dokumente hochladen" /
// "werden erstellt" copy must not appear when mode === 'edit'.
const { container } = render(BulkDocumentEditLayout, {
mode: 'edit',
initialEditEntries: [editEntry(1), editEntry(2)]
});
// Topbar title slot
const topbar = container.querySelector('span.font-bold.text-ink');
expect(topbar?.textContent).toContain('Massenbearbeitung');
// Count pill
const pill = container.querySelector('span.bg-accent');
expect(pill?.textContent).toContain('werden bearbeitet');
// Negative: must NOT show upload-flavoured copy
expect(topbar?.textContent ?? '').not.toContain('hochladen');
expect(pill?.textContent ?? '').not.toContain('werden erstellt');
});
it('shows the archiveBox and archiveFolder bulk-only inputs', async () => {
const { container } = render(BulkDocumentEditLayout, {
mode: 'edit',
initialEditEntries: [editEntry(1)]
});
expect(container.querySelector('[data-testid="description-archive-box"]')).not.toBeNull();
expect(container.querySelector('[data-testid="description-archive-folder"]')).not.toBeNull();
});
it('save calls PATCH /api/documents/bulk in edit mode', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ updated: 2, errors: [] })
});
vi.stubGlobal('fetch', mockFetch);
const { container } = render(BulkDocumentEditLayout, {
mode: 'edit',
initialEditEntries: [editEntry(1), editEntry(2)]
});
const saveBtn = container.querySelector(
'button[data-testid="bulk-save-btn"]'
) as HTMLButtonElement;
expect(saveBtn).not.toBeNull();
saveBtn.click();
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1), { timeout: 3000 });
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/documents/bulk');
expect(init.method).toBe('PATCH');
const body = JSON.parse(init.body);
expect(body.documentIds).toEqual(['doc-1', 'doc-2']);
});
it('chunks IDs into 500-sized PATCH requests', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ updated: 500, errors: [] })
});
vi.stubGlobal('fetch', mockFetch);
const entries = Array.from({ length: 1100 }, (_, i) => editEntry(i));
const { container } = render(BulkDocumentEditLayout, {
mode: 'edit',
initialEditEntries: entries
});
const saveBtn = container.querySelector(
'button[data-testid="bulk-save-btn"]'
) as HTMLButtonElement;
saveBtn.click();
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(3), { timeout: 5000 });
expect(JSON.parse(mockFetch.mock.calls[0][1].body).documentIds.length).toBe(500);
expect(JSON.parse(mockFetch.mock.calls[1][1].body).documentIds.length).toBe(500);
expect(JSON.parse(mockFetch.mock.calls[2][1].body).documentIds.length).toBe(100);
});
it('stops on chunk failure and shows the partial-failure alert with retry', async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce({ ok: true, json: async () => ({ updated: 500, errors: [] }) })
.mockResolvedValueOnce({ ok: false, json: async () => ({ code: 'INTERNAL_ERROR' }) });
vi.stubGlobal('fetch', mockFetch);
const entries = Array.from({ length: 1100 }, (_, i) => editEntry(i));
const { container } = render(BulkDocumentEditLayout, {
mode: 'edit',
initialEditEntries: entries
});
const saveBtn = container.querySelector(
'button[data-testid="bulk-save-btn"]'
) as HTMLButtonElement;
saveBtn.click();
await vi.waitFor(
() => {
const alert = container.querySelector('[data-testid="bulk-edit-partial-failure"]');
expect(alert).not.toBeNull();
},
{ timeout: 5000 }
);
// Should have called twice — chunks 0 and 1 — but not the third.
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(vi.mocked(goto)).not.toHaveBeenCalled();
});
it('marks per-document error chips when service returns errors[]', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
updated: 1,
errors: [{ id: 'doc-2', message: 'Sender not found' }]
})
})
);
const { container } = render(BulkDocumentEditLayout, {
mode: 'edit',
initialEditEntries: [editEntry(1), editEntry(2)]
});
const saveBtn = container.querySelector(
'button[data-testid="bulk-save-btn"]'
) as HTMLButtonElement;
saveBtn.click();
await vi.waitFor(
() => {
const errorChip = container.querySelector(
'[data-testid="file-switcher-strip"] [data-chip-id="doc-2"][data-status="error"]'
);
expect(errorChip).not.toBeNull();
},
{ timeout: 3000 }
);
});
});

View File

@@ -0,0 +1,80 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
let {
onFilesAdded
}: {
onFilesAdded: (files: File[]) => void;
} = $props();
let isDragging = $state(false);
</script>
<div
role="region"
aria-label={m.bulk_drop_zone_label()}
aria-describedby="bulk-drop-desc"
data-testid="bulk-drop-zone"
class="flex flex-1 flex-col items-center justify-center p-6"
ondragover={(e) => {
e.preventDefault();
isDragging = true;
}}
ondragleave={() => (isDragging = false)}
ondrop={(e) => {
e.preventDefault();
isDragging = false;
if (e.dataTransfer && e.dataTransfer.files.length > 0) {
onFilesAdded(Array.from(e.dataTransfer.files));
}
}}
>
<div
class={[
'flex w-full max-w-xl flex-col items-center gap-5 rounded-md border-2 border-dashed px-12 py-16 text-center transition-colors',
isDragging ? 'border-accent bg-accent/10' : 'border-accent/50 bg-white/[0.04]'
].join(' ')}
>
<!-- Circular mint icon -->
<div class="flex h-20 w-20 items-center justify-center rounded-full bg-accent text-primary">
<svg
width="32"
height="32"
viewBox="0 0 32 32"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<polygon
fill="currentColor"
points="6 12.5 16 2 26 12.5 24.5714286 14 16.999 6.049 17 30 15 30 14.999 6.051 7.42857143 14"
/>
</svg>
</div>
<!-- Serif title -->
<p class="font-serif text-base font-bold text-ink">{m.bulk_drop_hint()}</p>
<!-- Sub description -->
<p id="bulk-drop-desc" class="text-sm leading-relaxed text-ink-2">{m.bulk_drop_desc()}</p>
<!-- CTA button -->
<label
class="flex min-h-[44px] cursor-pointer items-center rounded-sm bg-primary px-6 py-2 text-xs font-bold tracking-widest text-primary-fg uppercase transition-opacity hover:opacity-90"
>
{m.bulk_select_files()}
<input
type="file"
multiple
accept="application/pdf"
class="sr-only"
onchange={(e) => {
const files = Array.from(e.currentTarget.files ?? []);
if (files.length > 0) onFilesAdded(files);
}}
/>
</label>
<!-- Format hint -->
<p class="text-xs text-ink-3">{m.bulk_drop_sub()}</p>
</div>
</div>

View File

@@ -0,0 +1,39 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser';
import BulkDropZone from './BulkDropZone.svelte';
afterEach(cleanup);
describe('BulkDropZone', () => {
it('file input has multiple attribute', async () => {
const { container } = render(BulkDropZone, { onFilesAdded: vi.fn() });
const input = container.querySelector('input[type="file"]');
expect(input).not.toBeNull();
expect(input?.hasAttribute('multiple')).toBe(true);
});
it('fires onFilesAdded with selected files when 3 files are picked via input', async () => {
const onFilesAdded = vi.fn();
render(BulkDropZone, { onFilesAdded });
const files = [
new File(['a'], 'a.pdf', { type: 'application/pdf' }),
new File(['b'], 'b.pdf', { type: 'application/pdf' }),
new File(['c'], 'c.pdf', { type: 'application/pdf' })
];
const input = page.getByRole('button', { name: /Dateien auswählen/i });
await userEvent.upload(input, files);
expect(onFilesAdded).toHaveBeenCalledOnce();
const received: File[] = onFilesAdded.mock.calls[0][0];
expect(received).toHaveLength(3);
expect(received.map((f) => f.name)).toEqual(['a.pdf', 'b.pdf', 'c.pdf']);
});
it('shows drop hint text', async () => {
render(BulkDropZone, { onFilesAdded: vi.fn() });
await expect.element(page.getByText(/hier ablegen/i)).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,74 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { m } from '$lib/paraglide/messages.js';
import { bulkSelectionStore } from '$lib/stores/bulkSelection.svelte';
let { canWrite }: { canWrite: boolean } = $props();
const count = $derived(bulkSelectionStore.size);
const visible = $derived(canWrite && count > 0);
function openBulkEdit() {
goto('/documents/bulk-edit');
}
function clearAll() {
bulkSelectionStore.clear();
}
// Escape clears the selection — keyboard escape hatch when the user has
// drilled into a 50-row selection and wants to bail without Tab-ing through
// the whole footer (WCAG 2.1.1). Bails when an open dialog, expanded menu,
// or popover is in front so we don't steal Esc from NotificationBell,
// ConfirmDialog, HelpPopover, etc.
function onEscape(e: KeyboardEvent) {
if (e.key !== 'Escape' || !visible) return;
if (e.defaultPrevented) return;
const overlay = document.querySelector(
'dialog[open], [aria-expanded="true"], [role="menu"]:not([hidden]), [role="dialog"]:not([hidden])'
);
if (overlay) return;
clearAll();
}
</script>
<svelte:window onkeydown={onEscape} />
{#if visible}
<div
data-testid="bulk-selection-bar"
class="fixed right-0 bottom-0 left-0 z-30 flex items-center justify-between gap-3 border-t border-line bg-surface px-4 py-3 pb-[max(0.75rem,env(safe-area-inset-bottom))] shadow-[0_-2px_8px_rgba(0,0,0,0.06)] sm:px-6"
>
<div class="flex items-baseline gap-3">
<span
class="font-sans text-sm font-medium text-ink"
data-testid="bulk-selection-count"
aria-live="polite"
aria-atomic="true"
>
{count === 1 ? m.bulk_edit_n_selected_one() : m.bulk_edit_n_selected_other({ count })}
</span>
<span class="hidden font-sans text-xs text-ink-3 sm:inline">
{m.bulk_edit_clear_hint_keyboard()}
</span>
</div>
<div class="flex items-center gap-2">
<button
type="button"
onclick={clearAll}
class="inline-flex min-h-[44px] items-center px-4 py-2 font-sans text-sm font-medium text-ink-2 transition-colors hover:text-ink"
data-testid="bulk-clear-all"
>
{m.bulk_edit_clear_selection()}
</button>
<button
type="button"
onclick={openBulkEdit}
class="inline-flex min-h-[44px] items-center bg-primary px-5 py-2 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-colors hover:bg-primary/90"
data-testid="bulk-edit-open"
>
{m.bulk_edit_button()}
</button>
</div>
</div>
{/if}

View File

@@ -0,0 +1,122 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import { goto } from '$app/navigation';
import BulkSelectionBar from './BulkSelectionBar.svelte';
import { bulkSelectionStore } from '$lib/stores/bulkSelection.svelte';
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
afterEach(() => {
cleanup();
vi.mocked(goto).mockClear();
bulkSelectionStore.clear();
});
describe('BulkSelectionBar', () => {
it('does not render when canWrite is false', async () => {
bulkSelectionStore.add('a');
render(BulkSelectionBar, { canWrite: false });
await expect.element(page.getByTestId('bulk-selection-bar')).not.toBeInTheDocument();
});
it('does not render when selection is empty', async () => {
render(BulkSelectionBar, { canWrite: true });
await expect.element(page.getByTestId('bulk-selection-bar')).not.toBeInTheDocument();
});
it('renders with the current selection count', async () => {
bulkSelectionStore.add('a');
bulkSelectionStore.add('b');
render(BulkSelectionBar, { canWrite: true });
await expect.element(page.getByTestId('bulk-selection-count')).toHaveTextContent('2');
});
it('uses the singular plural form for count=1 (not "1 Dokumente")', async () => {
bulkSelectionStore.add('only');
render(BulkSelectionBar, { canWrite: true });
await expect
.element(page.getByTestId('bulk-selection-count'))
.toHaveTextContent('1 Dokument ausgewählt');
});
it('uses the plural form for count=2', async () => {
bulkSelectionStore.add('a');
bulkSelectionStore.add('b');
render(BulkSelectionBar, { canWrite: true });
await expect
.element(page.getByTestId('bulk-selection-count'))
.toHaveTextContent('2 Dokumente ausgewählt');
});
it('clear button empties the store', async () => {
bulkSelectionStore.add('a');
bulkSelectionStore.add('b');
render(BulkSelectionBar, { canWrite: true });
await page.getByTestId('bulk-clear-all').click();
expect(bulkSelectionStore.size).toBe(0);
});
it('Massenbearbeitung navigates to /documents/bulk-edit', async () => {
bulkSelectionStore.add('a');
render(BulkSelectionBar, { canWrite: true });
await page.getByTestId('bulk-edit-open').click();
expect(vi.mocked(goto)).toHaveBeenCalledWith('/documents/bulk-edit');
});
it('selection count region announces via aria-live=polite', async () => {
bulkSelectionStore.add('a');
render(BulkSelectionBar, { canWrite: true });
await expect
.element(page.getByTestId('bulk-selection-count'))
.toHaveAttribute('aria-live', 'polite');
});
it('Escape clears the selection while the bar is visible', async () => {
bulkSelectionStore.add('a');
bulkSelectionStore.add('b');
render(BulkSelectionBar, { canWrite: true });
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
await expect.poll(() => bulkSelectionStore.size).toBe(0);
});
it('Escape is a no-op when the bar is hidden (no selection)', async () => {
render(BulkSelectionBar, { canWrite: true });
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
// Nothing to clear, no error.
expect(bulkSelectionStore.size).toBe(0);
});
it('Escape does not clear when an open <dialog> is present (Leonie B6 scope guard)', async () => {
bulkSelectionStore.add('a');
bulkSelectionStore.add('b');
render(BulkSelectionBar, { canWrite: true });
// Simulate a ConfirmDialog being open in front of the bar.
const overlay = document.createElement('dialog');
overlay.setAttribute('open', '');
document.body.appendChild(overlay);
try {
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
// Escape is captured by the dialog, not the bar — selection survives.
expect(bulkSelectionStore.size).toBe(2);
} finally {
overlay.remove();
}
});
it('Escape does not clear when an aria-expanded popover is present', async () => {
bulkSelectionStore.add('a');
render(BulkSelectionBar, { canWrite: true });
const trigger = document.createElement('button');
trigger.setAttribute('aria-expanded', 'true');
document.body.appendChild(trigger);
try {
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
expect(bulkSelectionStore.size).toBe(1);
} finally {
trigger.remove();
}
});
});

View File

@@ -1,30 +1,48 @@
<script lang="ts"> <script lang="ts">
import { untrack } from 'svelte'; import { onMount } from 'svelte';
import TagInput, { type Tag } from '$lib/components/TagInput.svelte'; import TagInput, { type Tag } from '$lib/components/TagInput.svelte';
import FieldLabelBadge from './FieldLabelBadge.svelte';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
let { let {
tags = $bindable<Tag[]>([]), tags = $bindable<Tag[]>([]),
currentTitle = $bindable(''), currentTitle = $bindable(''),
documentLocation = $bindable(''),
archiveBox = $bindable(''),
archiveFolder = $bindable(''),
initialTitle = '', initialTitle = '',
initialDocumentLocation = '', initialDocumentLocation = '',
initialSummary = '', initialSummary = '',
titleRequired = false, titleRequired = false,
suggestedTitle = '', suggestedTitle = '',
hideTitle = false hideTitle = false,
editMode = false
}: { }: {
tags?: Tag[]; tags?: Tag[];
currentTitle?: string; currentTitle?: string;
documentLocation?: string;
archiveBox?: string;
archiveFolder?: string;
initialTitle?: string; initialTitle?: string;
initialDocumentLocation?: string; initialDocumentLocation?: string;
initialSummary?: string; initialSummary?: string;
titleRequired?: boolean; titleRequired?: boolean;
suggestedTitle?: string; suggestedTitle?: string;
hideTitle?: boolean; hideTitle?: boolean;
editMode?: boolean;
} = $props(); } = $props();
// Seed bindables from initial-* props once at mount and only when the parent
// hasn't already supplied a non-empty value through the binding. onMount runs
// exactly once per instance, so this never stomps a parent-driven update on a
// later prop change. Required by the single-doc edit flow which seeds from
// the document; bulk-edit consumers leave the initial-* unset and bind their
// own state.
let titleDirty = $state(false); let titleDirty = $state(false);
currentTitle = untrack(() => initialTitle); onMount(() => {
if (!currentTitle && initialTitle) currentTitle = initialTitle;
if (!documentLocation && initialDocumentLocation) documentLocation = initialDocumentLocation;
});
const titleValue = $derived(titleDirty ? currentTitle : suggestedTitle || currentTitle); const titleValue = $derived(titleDirty ? currentTitle : suggestedTitle || currentTitle);
</script> </script>
@@ -67,40 +85,80 @@ const titleValue = $derived(titleDirty ? currentTitle : suggestedTitle || curren
<!-- Schlagworte (optional) --> <!-- Schlagworte (optional) -->
<div> <div>
<p class="mb-1 block text-sm font-medium text-ink-2">{m.form_label_tags()}</p> <p class="mb-1 block text-sm font-medium text-ink-2">
{m.form_label_tags()}
{#if editMode}<FieldLabelBadge variant="additive" />{/if}
</p>
<TagInput bind:tags={tags} /> <TagInput bind:tags={tags} />
<input type="hidden" name="tags" value={tags.map((t) => t.name).join(',')} /> <input type="hidden" name="tags" value={tags.map((t) => t.name).join(',')} />
</div> </div>
<!-- Inhalt (optional) --> {#if !editMode}
<div> <!-- Inhalt (optional) — not bulk-editable. -->
<label for="summary" class="mb-1 block text-sm font-medium text-ink-2" <div>
>{m.form_label_content()}</label <label for="summary" class="mb-1 block text-sm font-medium text-ink-2"
> >{m.form_label_content()}</label
<textarea >
id="summary" <textarea
name="summary" id="summary"
rows="5" name="summary"
placeholder={m.form_placeholder_content()} rows="5"
class="block w-full rounded border border-line p-2 font-serif text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring" placeholder={m.form_placeholder_content()}
>{initialSummary}</textarea class="block w-full rounded border border-line p-2 font-serif text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
> >{initialSummary}</textarea
</div> >
</div>
{/if}
<!-- Aufbewahrungsort (optional) --> <!-- Aufbewahrungsort (optional) -->
<div> <div data-testid="description-document-location">
<label for="documentLocation" class="mb-1 block text-sm font-medium text-ink-2" <label for="documentLocation" class="mb-1 block text-sm font-medium text-ink-2"
>{m.form_label_archive_location()}</label >{m.form_label_archive_location()}
> {#if editMode}<FieldLabelBadge variant="replace" />{/if}
</label>
<input <input
id="documentLocation" id="documentLocation"
type="text" type="text"
name="documentLocation" name="documentLocation"
value={initialDocumentLocation} bind:value={documentLocation}
placeholder={m.form_placeholder_archive_location()} placeholder={m.form_placeholder_archive_location()}
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring" class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/> />
<p class="mt-1 text-xs text-ink-3">{m.form_helper_archive_location()}</p> <p class="mt-1 text-xs text-ink-3">{m.form_helper_archive_location()}</p>
</div> </div>
{#if editMode}
<!-- Karton (only in editMode — bulk-editable replace) -->
<div data-testid="description-archive-box">
<label for="archiveBox" class="mb-1 block text-sm font-medium text-ink-2">
{m.form_label_archive_box()}
<FieldLabelBadge variant="replace" />
</label>
<input
id="archiveBox"
type="text"
name="archiveBox"
bind:value={archiveBox}
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
<p class="mt-1 text-xs text-ink-3">{m.form_helper_archive_box()}</p>
</div>
<!-- Mappe (only in editMode — bulk-editable replace) -->
<div data-testid="description-archive-folder">
<label for="archiveFolder" class="mb-1 block text-sm font-medium text-ink-2">
{m.form_label_archive_folder()}
<FieldLabelBadge variant="replace" />
</label>
<input
id="archiveFolder"
type="text"
name="archiveFolder"
bind:value={archiveFolder}
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
<p class="mt-1 text-xs text-ink-3">{m.form_helper_archive_folder()}</p>
</div>
{/if}
</div> </div>
</div> </div>

View File

@@ -0,0 +1,50 @@
import { afterEach, describe, expect, it } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import DescriptionSection from './DescriptionSection.svelte';
afterEach(() => cleanup());
describe('DescriptionSection — onMount seeding (Felix B1/B2 fix regression fence)', () => {
it('pre-fills the title input from initialTitle when currentTitle is empty', async () => {
render(DescriptionSection, { initialTitle: 'Brief an Anna' });
const titleInput = document.querySelector('input#title') as HTMLInputElement;
expect(titleInput).not.toBeNull();
expect(titleInput.value).toBe('Brief an Anna');
});
it('does not stomp a parent-bound currentTitle that is already non-empty', async () => {
render(DescriptionSection, {
currentTitle: 'Parent Title',
initialTitle: 'Should Not Win'
});
const titleInput = document.querySelector('input#title') as HTMLInputElement;
expect(titleInput.value).toBe('Parent Title');
});
it('pre-fills the documentLocation input from initialDocumentLocation', async () => {
render(DescriptionSection, { initialDocumentLocation: 'Schrank 3, Mappe B' });
const locationInput = document.querySelector('input#documentLocation') as HTMLInputElement;
expect(locationInput.value).toBe('Schrank 3, Mappe B');
});
it('does not stomp a parent-bound documentLocation that is already non-empty', async () => {
render(DescriptionSection, {
documentLocation: 'Bound Value',
initialDocumentLocation: 'Should Not Win'
});
const locationInput = document.querySelector('input#documentLocation') as HTMLInputElement;
expect(locationInput.value).toBe('Bound Value');
});
it('renders the editMode-only archiveBox + archiveFolder fields when editMode=true', async () => {
render(DescriptionSection, { editMode: true, hideTitle: true });
expect(document.querySelector('[data-testid="description-archive-box"]')).not.toBeNull();
expect(document.querySelector('[data-testid="description-archive-folder"]')).not.toBeNull();
});
it('hides the editMode-only archive fields when editMode=false', async () => {
render(DescriptionSection, { editMode: false });
expect(document.querySelector('[data-testid="description-archive-box"]')).toBeNull();
expect(document.querySelector('[data-testid="description-archive-folder"]')).toBeNull();
});
});

View File

@@ -0,0 +1,16 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
let { variant }: { variant: 'additive' | 'replace' } = $props();
const text = $derived(
variant === 'additive' ? m.bulk_edit_badge_additive() : m.bulk_edit_badge_replace()
);
</script>
<span
data-testid="field-label-badge-{variant}"
class="ml-2 inline-flex items-center rounded-sm bg-muted px-1.5 py-0.5 text-[11px] font-medium tracking-wide text-ink-2"
>
{text}
</span>

View File

@@ -0,0 +1,28 @@
import { afterEach, describe, expect, it } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import FieldLabelBadge from './FieldLabelBadge.svelte';
afterEach(() => cleanup());
describe('FieldLabelBadge', () => {
it('renders the additive variant text', async () => {
render(FieldLabelBadge, { variant: 'additive' });
await expect.element(page.getByTestId('field-label-badge-additive')).toBeInTheDocument();
await expect
.element(page.getByTestId('field-label-badge-additive'))
.toHaveTextContent('+ wird hinzugefügt');
});
it('renders the replace variant text', async () => {
render(FieldLabelBadge, { variant: 'replace' });
await expect
.element(page.getByTestId('field-label-badge-replace'))
.toHaveTextContent('wird ersetzt');
});
it('uses the design-system text-ink-2 token (not raw Tailwind palette)', async () => {
render(FieldLabelBadge, { variant: 'replace' });
await expect.element(page.getByTestId('field-label-badge-replace')).toHaveClass(/text-ink-2/);
});
});

View File

@@ -0,0 +1,154 @@
<script lang="ts">
import { tick } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
export interface FileEntry {
id: string;
/** Present in upload mode only. Edit mode entries reference an existing
* document by `documentId` and have no local file blob. */
file?: File;
/** Present in edit mode only — the server-side document UUID being edited. */
documentId?: string;
title: string;
status: 'idle' | 'error';
previewUrl: string;
}
let {
files,
activeId,
onSelect,
onRemove
}: {
files: FileEntry[];
activeId: string;
onSelect: (id: string) => void;
onRemove: (id: string) => void;
} = $props();
let trackEl = $state<HTMLDivElement | null>(null);
let listEl = $state<HTMLUListElement | null>(null);
const activeAnnouncement = $derived(files.find((f) => f.id === activeId)?.title ?? '');
function scrollPrev() {
trackEl?.scrollBy({ left: -120, behavior: 'smooth' });
}
function scrollNext() {
trackEl?.scrollBy({ left: 120, behavior: 'smooth' });
}
async function handleRemove(entry: FileEntry, index: number) {
const targetId = index > 0 ? files[index - 1].id : (files[index + 1]?.id ?? null);
onRemove(entry.id);
if (targetId) {
await tick();
(listEl?.querySelector<HTMLElement>(`[data-chip-id="${targetId}"]`) ?? null)?.focus();
}
}
$effect(() => {
if (!listEl) return;
const node = listEl;
function handleKeyDown(event: KeyboardEvent) {
const buttons = Array.from(node.querySelectorAll<HTMLElement>('[data-chip-id]'));
if (buttons.length === 0) return;
const focusedIndex = buttons.indexOf(document.activeElement as HTMLElement);
if (focusedIndex === -1) return;
if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
event.preventDefault();
const nextIndex = (focusedIndex + 1) % buttons.length;
buttons[nextIndex].focus();
} else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
event.preventDefault();
const prevIndex = (focusedIndex - 1 + buttons.length) % buttons.length;
buttons[prevIndex].focus();
}
}
node.addEventListener('keydown', handleKeyDown);
return () => node.removeEventListener('keydown', handleKeyDown);
});
</script>
<div aria-live="polite" aria-atomic="true" class="sr-only">{activeAnnouncement}</div>
<div
data-testid="file-switcher-strip"
class="flex h-11 shrink-0 items-center gap-1 border-t border-line bg-pdf-ctrl px-2"
>
<button
type="button"
aria-label={m.bulk_switcher_prev()}
onclick={scrollPrev}
class="flex h-[44px] w-[44px] shrink-0 items-center justify-center rounded-sm text-sm text-ink-3 hover:bg-black/10 hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
></button
>
<!-- Gradient fade overlays signal hidden overflow to pointer-only users -->
<div class="relative flex flex-1 overflow-hidden">
<div
class="pointer-events-none absolute inset-y-0 left-0 z-10 w-6 bg-gradient-to-r from-pdf-ctrl to-transparent"
></div>
<div
class="pointer-events-none absolute inset-y-0 right-0 z-10 w-6 bg-gradient-to-l from-pdf-ctrl to-transparent"
></div>
<div bind:this={trackEl} class="flex flex-1 gap-1 overflow-x-auto" style="scrollbar-width:none">
<ul bind:this={listEl} role="list" class="flex flex-row gap-1 py-1">
{#each files as entry, i (entry.id)}
<li role="listitem" class="inline-flex shrink-0 items-center">
<button
type="button"
tabindex="0"
aria-current={entry.id === activeId ? 'true' : undefined}
data-status={entry.status}
data-chip-id={entry.id}
onclick={() => onSelect(entry.id)}
class={[
'inline-flex cursor-pointer items-center gap-1 rounded-[2px] px-1.5 py-0.5 text-xs font-bold transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent',
entry.id === activeId
? 'bg-accent text-primary'
: 'bg-black/[0.06] text-ink-2 hover:bg-black/10',
entry.status === 'error'
? '!border !border-dashed !border-red-400 !bg-red-50/80 !text-red-700'
: ''
].join(' ')}
>
<span
class={[
'rounded-[2px] px-0.5 text-[11px] font-extrabold opacity-85',
entry.id === activeId ? 'bg-black/20' : 'bg-black/10'
].join(' ')}
>{i + 1}</span
>
<span class="max-w-[8rem] truncate" title={entry.title}>{entry.title}</span>
{#if entry.status === 'error'}
<span class="sr-only">{m.bulk_file_error_chip_label()}</span>
<span aria-hidden="true" class="ml-0.5 font-extrabold text-red-600">!</span>
{/if}
</button>
<button
type="button"
aria-label={m.bulk_remove_file()}
data-remove-id={entry.id}
onclick={() => handleRemove(entry, i)}
class="ml-0.5 flex h-[44px] w-[44px] items-center justify-center text-base text-ink-3 hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
×
</button>
</li>
{/each}
</ul>
</div>
</div>
<button
type="button"
aria-label={m.bulk_switcher_next()}
onclick={scrollNext}
class="flex h-[44px] w-[44px] shrink-0 items-center justify-center rounded-sm text-sm text-ink-3 hover:bg-black/10 hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
></button
>
</div>

View File

@@ -0,0 +1,145 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser';
import FileSwitcherStrip from './FileSwitcherStrip.svelte';
import type { FileEntry } from './FileSwitcherStrip.svelte';
afterEach(cleanup);
function makeFiles(n: number): FileEntry[] {
return Array.from({ length: n }, (_, i) => ({
id: `id-${i}`,
file: new File([''], `file${i}.pdf`),
title: `File ${i}`,
status: 'idle' as const,
previewUrl: ''
}));
}
describe('FileSwitcherStrip', () => {
it('renders N chips for N files', async () => {
const files = makeFiles(4);
render(FileSwitcherStrip, {
files,
activeId: files[0].id,
onSelect: vi.fn(),
onRemove: vi.fn()
});
const chips = page.getByRole('listitem');
await expect.element(chips.nth(0)).toBeInTheDocument();
await expect.element(chips.nth(3)).toBeInTheDocument();
});
it('active chip has aria-current="true"', async () => {
const files = makeFiles(3);
const { container } = render(FileSwitcherStrip, {
files,
activeId: files[1].id,
onSelect: vi.fn(),
onRemove: vi.fn()
});
const activeBtn = container.querySelector('[aria-current="true"]');
expect(activeBtn).not.toBeNull();
expect(activeBtn?.textContent).toContain('File 1');
});
it('clicking a chip fires onSelect with its id', async () => {
const files = makeFiles(3);
const onSelect = vi.fn();
const { container } = render(FileSwitcherStrip, {
files,
activeId: files[0].id,
onSelect,
onRemove: vi.fn()
});
const chip = container.querySelector('[data-chip-id="id-2"]') as HTMLElement;
expect(chip).not.toBeNull();
chip.click();
expect(onSelect).toHaveBeenCalledWith('id-2');
});
it('error chip has aria-label containing warning indicator', async () => {
const files: FileEntry[] = [
{
id: 'e1',
file: new File([''], 'bad.pdf'),
title: 'Bad file',
status: 'error',
previewUrl: ''
}
];
const { container } = render(FileSwitcherStrip, {
files,
activeId: 'e1',
onSelect: vi.fn(),
onRemove: vi.fn()
});
const errBtn = container.querySelector('[data-status="error"]');
expect(errBtn).not.toBeNull();
});
it('error chip contains a screen-reader-only error label', async () => {
const files: FileEntry[] = [
{
id: 'e1',
file: new File([''], 'bad.pdf'),
title: 'Bad file',
status: 'error',
previewUrl: ''
}
];
const { container } = render(FileSwitcherStrip, {
files,
activeId: 'e1',
onSelect: vi.fn(),
onRemove: vi.fn()
});
const errBtn = container.querySelector('[data-status="error"]');
const srOnly = errBtn?.querySelector('.sr-only');
expect(srOnly).not.toBeNull();
});
it('focus moves to the previous chip after the middle chip is removed', async () => {
const files = makeFiles(3); // id-0, id-1, id-2
const onRemove = vi.fn();
const { container } = render(FileSwitcherStrip, {
files,
activeId: files[1].id,
onSelect: vi.fn(),
onRemove
});
const removeBtn = container.querySelector('[data-remove-id="id-1"]') as HTMLButtonElement;
expect(removeBtn).not.toBeNull();
removeBtn.click();
expect(onRemove).toHaveBeenCalledWith('id-1');
// After removal, focus should be on the chip for id-0 (the previous chip)
await vi.waitFor(
() => {
const prevChip = container.querySelector('[data-chip-id="id-0"]') as HTMLElement | null;
expect(prevChip).not.toBeNull();
expect(document.activeElement).toBe(prevChip);
},
{ timeout: 1000 }
);
});
it('ArrowRight moves focus to next chip without leaving strip', async () => {
const files = makeFiles(3);
const { container } = render(FileSwitcherStrip, {
files,
activeId: files[0].id,
onSelect: vi.fn(),
onRemove: vi.fn()
});
const firstBtn = container.querySelectorAll('[data-chip-id]')[0] as HTMLElement;
firstBtn.focus();
await userEvent.keyboard('{ArrowRight}');
const focused = document.activeElement;
expect(focused).not.toBe(firstBtn);
// The new focused element should still be inside the strip
const strip = container.querySelector('[data-testid="file-switcher-strip"]');
expect(strip?.contains(focused)).toBe(true);
});
});

View File

@@ -0,0 +1,40 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
let {
variant,
count = 0,
children
}: {
variant: 'per-file' | 'shared';
count?: number;
children?: import('svelte').Snippet;
} = $props();
</script>
<div
data-testid="scope-card"
data-variant={variant}
class="mb-3 rounded-sm border p-4
{variant === 'per-file'
? 'border-accent bg-accent-bg'
: 'border-line bg-surface'}"
>
{#if variant === 'shared'}
<div class="mb-3 flex items-center justify-between">
<span class="text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.bulk_scope_shared_label({ count })}
</span>
<span
class="inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-accent px-1.5 text-xs font-bold text-primary"
>
{count}
</span>
</div>
{:else}
<p class="mb-3 text-xs font-bold tracking-widest text-primary uppercase">
{m.bulk_scope_per_file_label()}
</p>
{/if}
{@render children?.()}
</div>

View File

@@ -0,0 +1,32 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import ScopeCard from './ScopeCard.svelte';
afterEach(cleanup);
describe('ScopeCard', () => {
it('per-file variant has accent background class', async () => {
const { container } = render(ScopeCard, { variant: 'per-file', count: 1 });
const card = container.querySelector('[data-testid="scope-card"]');
expect(card?.className).toMatch(/bg-accent-bg/);
});
it('shared variant does not have accent background', async () => {
const { container } = render(ScopeCard, { variant: 'shared', count: 3 });
const card = container.querySelector('[data-testid="scope-card"]');
expect(card?.className).not.toMatch(/bg-accent-bg/);
});
it('shared variant renders count badge with file count', async () => {
render(ScopeCard, { variant: 'shared', count: 5 });
await expect.element(page.getByText('5', { exact: true })).toBeInTheDocument();
});
it('per-file variant renders slot content', async () => {
// ScopeCard is a container — verify it renders children
render(ScopeCard, { variant: 'per-file', count: 1 });
const card = await page.getByTestId('scope-card');
await expect.element(card).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,69 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
let {
fileCount,
chunkProgress,
onSave,
onDiscard,
disabled = false,
editMode = false
}: {
fileCount: number;
chunkProgress?: { done: number; total: number };
onSave: () => void;
onDiscard: () => void | Promise<void>;
disabled?: boolean;
editMode?: boolean;
} = $props();
const saveCta = $derived.by(() => {
if (editMode) return m.bulk_edit_save_button();
return fileCount === 1 ? m.bulk_save_cta_one() : m.bulk_save_cta({ count: fileCount });
});
</script>
<div class="shrink-0 border-t border-line bg-surface px-4 py-3">
{#if chunkProgress}
<progress
value={chunkProgress.done}
max={chunkProgress.total}
aria-valuenow={chunkProgress.done}
aria-valuemin={0}
aria-valuemax={chunkProgress.total}
aria-label={editMode
? m.bulk_edit_save_progress({ done: chunkProgress.done, total: chunkProgress.total })
: m.bulk_upload_progress({ done: chunkProgress.done, total: chunkProgress.total })}
class="[&::-webkit-progress-bar]:bg-brand-sand mb-2 h-1 w-full rounded-full [&::-webkit-progress-bar]:rounded-full [&::-webkit-progress-value]:rounded-full [&::-webkit-progress-value]:bg-accent"
></progress>
{#if editMode && chunkProgress.total > 1}
<!-- Visible progress text for sighted users on multi-chunk PATCH
(Elicit S3 — the unitless bar isn't enough for a 30-second op). -->
<p
class="mb-2 font-sans text-xs text-ink-2"
aria-live="polite"
data-testid="bulk-edit-chunk-progress-text"
>
{m.bulk_edit_save_progress({ done: chunkProgress.done, total: chunkProgress.total })}
</p>
{/if}
{/if}
<div class="flex items-center justify-between gap-3">
<button
type="button"
onclick={onDiscard}
class="flex min-h-[44px] items-center px-2 text-sm text-red-600/70 hover:text-red-700"
>
{m.bulk_discard_all()}
</button>
<button
type="button"
data-testid="bulk-save-btn"
disabled={fileCount === 0 || disabled}
onclick={onSave}
class="min-h-[44px] rounded-sm bg-primary px-6 text-sm font-bold tracking-widest text-primary-fg uppercase transition-opacity hover:opacity-90 disabled:opacity-40"
>
{saveCta}
</button>
</div>
</div>

View File

@@ -0,0 +1,48 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import UploadSaveBar from './UploadSaveBar.svelte';
afterEach(cleanup);
describe('UploadSaveBar', () => {
it('shows plural label for multiple files', async () => {
render(UploadSaveBar, { fileCount: 5, onSave: vi.fn(), onDiscard: vi.fn() });
// "5 speichern →" or similar plural form
await expect.element(page.getByText(/5/)).toBeInTheDocument();
});
it('shows singular label for one file', async () => {
render(UploadSaveBar, { fileCount: 1, onSave: vi.fn(), onDiscard: vi.fn() });
// "Speichern →" singular form
await expect.element(page.getByText(/Speichern/i)).toBeInTheDocument();
});
it('progress bar is visible when chunkProgress is provided', async () => {
const { container } = render(UploadSaveBar, {
fileCount: 3,
chunkProgress: { done: 1, total: 3 },
onSave: vi.fn(),
onDiscard: vi.fn()
});
const progress = container.querySelector('progress');
expect(progress).not.toBeNull();
expect(progress?.getAttribute('value')).toBe('1');
expect(progress?.getAttribute('max')).toBe('3');
});
it('progress bar is not rendered when no chunkProgress', async () => {
const { container } = render(UploadSaveBar, {
fileCount: 2,
onSave: vi.fn(),
onDiscard: vi.fn()
});
const progress = container.querySelector('progress');
expect(progress).toBeNull();
});
it('discard link is rendered', async () => {
render(UploadSaveBar, { fileCount: 2, onSave: vi.fn(), onDiscard: vi.fn() });
await expect.element(page.getByText(/verwerfen/i)).toBeInTheDocument();
});
});

View File

@@ -1,7 +1,8 @@
<script lang="ts"> <script lang="ts">
import { untrack } from 'svelte'; import { onMount, untrack } from 'svelte';
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte'; import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
import PersonMultiSelect from '$lib/components/PersonMultiSelect.svelte'; import PersonMultiSelect from '$lib/components/PersonMultiSelect.svelte';
import FieldLabelBadge from './FieldLabelBadge.svelte';
import { isoToGerman, handleGermanDateInput } from '$lib/utils/date'; import { isoToGerman, handleGermanDateInput } from '$lib/utils/date';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import type { components } from '$lib/generated/api'; import type { components } from '$lib/generated/api';
@@ -16,7 +17,9 @@ let {
initialLocation = '', initialLocation = '',
initialSenderName = '', initialSenderName = '',
suggestedDateIso = '', suggestedDateIso = '',
suggestedSenderName = '' suggestedSenderName = '',
hideDate = false,
editMode = false
}: { }: {
senderId?: string; senderId?: string;
selectedReceivers?: Person[]; selectedReceivers?: Person[];
@@ -26,12 +29,24 @@ let {
initialSenderName?: string; initialSenderName?: string;
suggestedDateIso?: string; suggestedDateIso?: string;
suggestedSenderName?: string; suggestedSenderName?: string;
hideDate?: boolean;
editMode?: boolean;
} = $props(); } = $props();
let dateDisplay = $state(untrack(() => isoToGerman(initialDateIso))); // dateDisplay seeds from the bindable's value or initialDateIso once at mount
dateIso = untrack(() => initialDateIso); // and is then user-driven. onMount runs exactly once, so this never stomps
// the parent's dateIso on a later prop change.
let dateDisplay = $state('');
let dateDirty = $state(false); let dateDirty = $state(false);
onMount(() => {
const seed = dateIso || initialDateIso;
if (seed) {
dateDisplay = isoToGerman(seed);
if (!dateIso) dateIso = seed;
}
});
const dateInvalid = $derived(dateDirty && dateDisplay.length > 0 && dateIso === ''); const dateInvalid = $derived(dateDirty && dateDisplay.length > 0 && dateIso === '');
function handleDateInput(e: Event) { function handleDateInput(e: Event) {
@@ -56,62 +71,72 @@ $effect(() => {
</h2> </h2>
<div class="grid grid-cols-1 gap-5 md:grid-cols-2"> <div class="grid grid-cols-1 gap-5 md:grid-cols-2">
<!-- Datum (required — row 1, col 1) --> {#if !hideDate}
<div> <!-- Datum (required — row 1, col 1) -->
<label for="documentDate" class="mb-1 block text-sm font-medium text-ink-2" <div data-testid="who-when-date">
>{m.form_label_date()}*</label <label for="documentDate" class="mb-1 block text-sm font-medium text-ink-2"
> >{m.form_label_date()}*</label
<input >
id="documentDate" <input
type="text" id="documentDate"
inputmode="numeric" type="text"
value={dateDisplay} inputmode="numeric"
oninput={handleDateInput} value={dateDisplay}
placeholder={m.form_placeholder_date()} oninput={handleDateInput}
maxlength="10" placeholder={m.form_placeholder_date()}
autofocus={!initialDateIso} maxlength="10"
class="block w-full rounded border border-line p-2 text-sm shadow-sm class="block w-full rounded border border-line px-2 py-3 text-sm shadow-sm
{dateInvalid ? 'border-red-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500' : 'focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'}" {dateInvalid
aria-describedby={dateInvalid ? 'date-error' : undefined} ? 'border-red-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500'
/> : 'focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'}"
<input type="hidden" name="documentDate" value={dateIso} /> aria-describedby={dateInvalid ? 'date-error' : undefined}
{#if dateInvalid} />
<p id="date-error" class="mt-1 text-xs text-red-600">{m.form_date_error()}</p> <input type="hidden" name="documentDate" value={dateIso} />
{/if} {#if dateInvalid}
</div> <p id="date-error" class="mt-1 text-xs text-red-600">{m.form_date_error()}</p>
{/if}
</div>
{/if}
<!-- Absender (required — row 1, col 2) --> <!-- Absender (required in upload mode — row 1, col 2) -->
<div> <div>
<PersonTypeahead <PersonTypeahead
name="senderId" name="senderId"
label={m.form_label_sender()} label={m.form_label_sender()}
required={true} required={!editMode}
bind:value={senderId} bind:value={senderId}
initialName={initialSenderName} initialName={initialSenderName}
suggestedName={suggestedSenderName} suggestedName={suggestedSenderName}
autofocus={!!initialDateIso} badge={editMode ? 'replace' : undefined}
/> />
</div> </div>
<!-- Empfänger (optional — row 2, col 1) --> <!-- Empfänger (optional — row 2, col 1) -->
<div> <div>
<p class="mb-1 block text-sm font-medium text-ink-2">{m.form_label_receivers()}</p> <p class="mb-1 block text-sm font-medium text-ink-2">
{m.form_label_receivers()}
{#if editMode}<FieldLabelBadge variant="additive" />{/if}
</p>
<PersonMultiSelect bind:selectedPersons={selectedReceivers} /> <PersonMultiSelect bind:selectedPersons={selectedReceivers} />
</div> </div>
<!-- Ort (optional — row 2, col 2) --> {#if !editMode}
<div> <!-- Ort (optional — row 2, col 2). Hidden in editMode: meta_location is
<label for="location" class="mb-1 block text-sm font-medium text-ink-2" NOT bulk-editable per the issue spec; the three editable location
>{m.form_label_location()}</label fields live in DescriptionSection. -->
> <div>
<input <label for="location" class="mb-1 block text-sm font-medium text-ink-2"
id="location" >{m.form_label_location()}</label
type="text" >
name="location" <input
value={initialLocation} id="location"
placeholder={m.form_placeholder_location()} type="text"
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring" name="location"
/> value={initialLocation}
</div> placeholder={m.form_placeholder_location()}
class="block w-full rounded border border-line px-2 py-3 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>
{/if}
</div> </div>
</div> </div>

View File

@@ -0,0 +1,42 @@
import { afterEach, describe, expect, it } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import WhoWhenSection from './WhoWhenSection.svelte';
afterEach(() => cleanup());
describe('WhoWhenSection — onMount seeding (Felix B1 fix regression fence)', () => {
it('pre-fills the date input from initialDateIso when the bindable is empty', async () => {
render(WhoWhenSection, { initialDateIso: '2024-03-15' });
// isoToGerman('2024-03-15') → '15.03.2024'
const dateInput = document.querySelector('input#documentDate') as HTMLInputElement;
expect(dateInput).not.toBeNull();
expect(dateInput.value).toBe('15.03.2024');
});
it('does not stomp a parent-bound dateIso that is already non-empty', async () => {
// dateIso bindable is '2026-01-01' from the parent; initialDateIso is the
// "fallback seed". onMount must not overwrite the already-bound value.
render(WhoWhenSection, { dateIso: '2026-01-01', initialDateIso: '1900-01-01' });
const dateInput = document.querySelector('input#documentDate') as HTMLInputElement;
expect(dateInput.value).toBe('01.01.2026');
});
it('hides the date field when hideDate=true (bulk-edit mode)', async () => {
render(WhoWhenSection, { hideDate: true });
await expect.element(page.getByTestId('who-when-date')).not.toBeInTheDocument();
});
it('renders the meta_location input only outside editMode', async () => {
const { rerender } = render(WhoWhenSection, { editMode: true });
expect(document.querySelector('input#location')).toBeNull();
await rerender({ editMode: false });
expect(document.querySelector('input#location')).not.toBeNull();
});
it('pre-fills the location input from initialLocation', async () => {
render(WhoWhenSection, { editMode: false, initialLocation: 'Berlin' });
const locationInput = document.querySelector('input#location') as HTMLInputElement;
expect(locationInput.value).toBe('Berlin');
});
});

View File

@@ -41,6 +41,8 @@ export type ErrorCode =
| 'UNAUTHORIZED' | 'UNAUTHORIZED'
| 'FORBIDDEN' | 'FORBIDDEN'
| 'VALIDATION_ERROR' | 'VALIDATION_ERROR'
| 'BATCH_TOO_LARGE'
| 'BULK_EDIT_TOO_MANY_IDS'
| 'INTERNAL_ERROR'; | 'INTERNAL_ERROR';
export interface BackendError { export interface BackendError {
@@ -139,6 +141,10 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
return m.error_forbidden(); return m.error_forbidden();
case 'VALIDATION_ERROR': case 'VALIDATION_ERROR':
return m.error_validation_error(); return m.error_validation_error();
case 'BATCH_TOO_LARGE':
return m.error_batch_too_large();
case 'BULK_EDIT_TOO_MANY_IDS':
return m.error_bulk_edit_too_many_ids();
default: default:
return m.error_internal_error(); return m.error_internal_error();
} }

View File

@@ -484,6 +484,22 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/documents/batch-metadata": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["batchMetadata"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/auth/reset-password": { "/api/auth/reset-password": {
parameters: { parameters: {
query?: never; query?: never;
@@ -548,6 +564,22 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/admin/generate-thumbnails": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["generateThumbnails"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/admin/backfill-versions": { "/api/admin/backfill-versions": {
parameters: { parameters: {
query?: never; query?: never;
@@ -660,6 +692,22 @@ export interface paths {
patch: operations["updateAnnotation"]; patch: operations["updateAnnotation"];
trace?: never; trace?: never;
}; };
"/api/documents/bulk": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch: operations["patchBulk"];
trace?: never;
};
"/api/users/search": { "/api/users/search": {
parameters: { parameters: {
query?: never; query?: never;
@@ -1028,6 +1076,22 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/documents/{id}/thumbnail": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getDocumentThumbnail"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/documents/{documentId}/transcription-blocks/{blockId}/history": { "/api/documents/{documentId}/transcription-blocks/{blockId}/history": {
parameters: { parameters: {
query?: never; query?: never;
@@ -1124,6 +1188,22 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/documents/ids": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getDocumentIds"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/documents/conversation": { "/api/documents/conversation": {
parameters: { parameters: {
query?: never; query?: never;
@@ -1204,6 +1284,22 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/admin/thumbnail-status": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["thumbnailStatus"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/admin/import-status": { "/api/admin/import-status": {
parameters: { parameters: {
query?: never; query?: never;
@@ -1386,6 +1482,10 @@ export interface components {
thumbnailKey?: string; thumbnailKey?: string;
/** Format: date-time */ /** Format: date-time */
thumbnailGeneratedAt?: string; thumbnailGeneratedAt?: string;
/** @enum {string} */
thumbnailAspect?: "PORTRAIT" | "LANDSCAPE";
/** Format: int32 */
pageCount?: number;
originalFilename: string; originalFilename: string;
/** @enum {string} */ /** @enum {string} */
status: "PLACEHOLDER" | "UPLOADED" | "TRANSCRIBED" | "REVIEWED" | "ARCHIVED"; status: "PLACEHOLDER" | "UPLOADED" | "TRANSCRIBED" | "REVIEWED" | "ARCHIVED";
@@ -1408,6 +1508,7 @@ export interface components {
sender?: components["schemas"]["Person"]; sender?: components["schemas"]["Person"];
tags?: components["schemas"]["Tag"][]; tags?: components["schemas"]["Tag"][];
trainingLabels?: ("KURRENT_RECOGNITION" | "KURRENT_SEGMENTATION")[]; trainingLabels?: ("KURRENT_RECOGNITION" | "KURRENT_SEGMENTATION")[];
thumbnailUrl?: string;
}; };
UpdateTranscriptionBlockDTO: { UpdateTranscriptionBlockDTO: {
text?: string; text?: string;
@@ -1634,6 +1735,17 @@ export interface components {
/** Format: date-time */ /** Format: date-time */
createdAt: string; createdAt: string;
}; };
DocumentBatchMetadataDTO: {
titles?: string[];
/** Format: uuid */
senderId?: string;
receiverIds?: string[];
/** Format: date */
documentDate?: string;
location?: string;
tagNames?: string[];
metadataComplete?: boolean;
};
QuickUploadResult: { QuickUploadResult: {
created?: components["schemas"]["Document"][]; created?: components["schemas"]["Document"][];
updated?: components["schemas"]["Document"][]; updated?: components["schemas"]["Document"][];
@@ -1643,6 +1755,15 @@ export interface components {
filename?: string; filename?: string;
code?: string; code?: string;
}; };
BatchMetadataRequest: {
ids: string[];
};
DocumentBatchSummary: {
/** Format: uuid */
id: string;
title: string;
pdfUrl: string;
};
ResetPasswordRequest: { ResetPasswordRequest: {
token?: string; token?: string;
newPassword?: string; newPassword?: string;
@@ -1668,6 +1789,21 @@ export interface components {
/** Format: date-time */ /** Format: date-time */
startedAt?: string; startedAt?: string;
}; };
BackfillStatus: {
/** @enum {string} */
state?: "IDLE" | "RUNNING" | "DONE" | "FAILED";
message?: string;
/** Format: int32 */
total?: number;
/** Format: int32 */
processed?: number;
/** Format: int32 */
skipped?: number;
/** Format: int32 */
failed?: number;
/** Format: date-time */
startedAt?: string;
};
BackfillResult: { BackfillResult: {
/** Format: int32 */ /** Format: int32 */
count: number; count: number;
@@ -1703,6 +1839,26 @@ export interface components {
/** Format: double */ /** Format: double */
height?: number; height?: number;
}; };
DocumentBulkEditDTO: {
documentIds?: string[];
tagNames?: string[];
/** Format: uuid */
senderId?: string;
receiverIds?: string[];
documentLocation?: string;
archiveBox?: string;
archiveFolder?: string;
};
BulkEditError: {
/** Format: uuid */
id: string;
message: string;
};
BulkEditResult: {
/** Format: int32 */
updated: number;
errors: components["schemas"]["BulkEditError"][];
};
TranscriptionWeeklyStatsDTO: { TranscriptionWeeklyStatsDTO: {
/** Format: int64 */ /** Format: int64 */
segmentationCount: number; segmentationCount: number;
@@ -1754,7 +1910,6 @@ export interface components {
/** Format: uuid */ /** Format: uuid */
id?: string; id?: string;
displayName?: string; displayName?: string;
personType?: string;
firstName?: string; firstName?: string;
lastName?: string; lastName?: string;
/** Format: int64 */ /** Format: int64 */
@@ -1765,6 +1920,7 @@ export interface components {
deathYear?: number; deathYear?: number;
alias?: string; alias?: string;
notes?: string; notes?: string;
personType?: string;
}; };
SenderModel: { SenderModel: {
/** Format: uuid */ /** Format: uuid */
@@ -1832,10 +1988,10 @@ export interface components {
timeout?: number; timeout?: number;
}; };
PageNotificationDTO: { PageNotificationDTO: {
/** Format: int32 */
totalPages?: number;
/** Format: int64 */ /** Format: int64 */
totalElements?: number; totalElements?: number;
/** Format: int32 */
totalPages?: number;
pageable?: components["schemas"]["PageableObject"]; pageable?: components["schemas"]["PageableObject"];
first?: boolean; first?: boolean;
last?: boolean; last?: boolean;
@@ -1916,7 +2072,13 @@ export interface components {
DocumentSearchResult: { DocumentSearchResult: {
items: components["schemas"]["DocumentSearchItem"][]; items: components["schemas"]["DocumentSearchItem"][];
/** Format: int64 */ /** Format: int64 */
total: number; totalElements: number;
/** Format: int32 */
pageNumber: number;
/** Format: int32 */
pageSize: number;
/** Format: int32 */
totalPages: number;
}; };
MatchOffset: { MatchOffset: {
/** Format: int32 */ /** Format: int32 */
@@ -3141,6 +3303,7 @@ export interface operations {
content: { content: {
"multipart/form-data": { "multipart/form-data": {
files?: string[]; files?: string[];
metadata?: components["schemas"]["DocumentBatchMetadataDTO"];
}; };
}; };
}; };
@@ -3156,6 +3319,30 @@ export interface operations {
}; };
}; };
}; };
batchMetadata: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["BatchMetadataRequest"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["DocumentBatchSummary"][];
};
};
};
};
resetPassword: { resetPassword: {
parameters: { parameters: {
query?: never; query?: never;
@@ -3244,6 +3431,26 @@ export interface operations {
}; };
}; };
}; };
generateThumbnails: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["BackfillStatus"];
};
};
};
};
backfillVersions: { backfillVersions: {
parameters: { parameters: {
query?: never; query?: never;
@@ -3472,6 +3679,30 @@ export interface operations {
}; };
}; };
}; };
patchBulk: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["DocumentBulkEditDTO"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["BulkEditResult"];
};
};
};
};
search: { search: {
parameters: { parameters: {
query?: { query?: {
@@ -3964,6 +4195,28 @@ export interface operations {
}; };
}; };
}; };
getDocumentThumbnail: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": string;
};
};
};
};
getBlockHistory: { getBlockHistory: {
parameters: { parameters: {
query?: never; query?: never;
@@ -4027,6 +4280,10 @@ export interface operations {
dir?: string; dir?: string;
/** @description Tag operator: AND (default) or OR */ /** @description Tag operator: AND (default) or OR */
tagOp?: string; tagOp?: string;
/** @description Page number (0-indexed) */
page?: number;
/** @description Page size (max 100) */
size?: number;
}; };
header?: never; header?: never;
path?: never; path?: never;
@@ -4112,6 +4369,36 @@ export interface operations {
}; };
}; };
}; };
getDocumentIds: {
parameters: {
query?: {
q?: string;
from?: string;
to?: string;
senderId?: string;
receiverId?: string;
tag?: string[];
tagQ?: string;
status?: "PLACEHOLDER" | "UPLOADED" | "TRANSCRIBED" | "REVIEWED" | "ARCHIVED";
tagOp?: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": string[];
};
};
};
};
getConversation: { getConversation: {
parameters: { parameters: {
query: { query: {
@@ -4224,6 +4511,26 @@ export interface operations {
}; };
}; };
}; };
thumbnailStatus: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["BackfillStatus"];
};
};
};
};
importStatus: { importStatus: {
parameters: { parameters: {
query?: never; query?: never;

View File

@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { relativeTimeDe } from './relativeTime'; import { relativeTimeDe, relativeYearsDe } from './relativeTime';
const NOW = new Date('2026-04-20T12:00:00Z'); const NOW = new Date('2026-04-20T12:00:00Z');
@@ -39,3 +39,31 @@ describe('relativeTimeDe', () => {
expect(relativeTimeDe(invalid, NOW)).toMatch(/Minute/i); expect(relativeTimeDe(invalid, NOW)).toMatch(/Minute/i);
}); });
}); });
describe('relativeYearsDe', () => {
it('returns singular "vor 1 Jahr" for exactly one whole year ago', () => {
const from = new Date('2025-04-20T12:00:00Z');
expect(relativeYearsDe(from, NOW)).toBe('vor 1 Jahr');
});
it('returns plural "vor N Jahren" for more than one year', () => {
const from = new Date('1940-04-20T12:00:00Z');
expect(relativeYearsDe(from, NOW)).toBe('vor 86 Jahren');
});
it('floors a partial year down (eleven months ago = 0 years)', () => {
const from = new Date('2025-06-01T00:00:00Z');
// We show "vor weniger als 1 Jahr" rather than rounding up to 1.
expect(relativeYearsDe(from, NOW)).toBe('vor weniger als 1 Jahr');
});
it('returns empty string when the input Date is invalid', () => {
const invalid = new Date('not-a-real-date');
expect(relativeYearsDe(invalid, NOW)).toBe('');
});
it('returns empty string for future dates', () => {
const future = new Date('2030-01-01T00:00:00Z');
expect(relativeYearsDe(future, NOW)).toBe('');
});
});

View File

@@ -9,3 +9,22 @@ export function relativeTimeDe(from: Date, now: Date = new Date()): string {
if (minutes < 1440) return m.comment_time_hours({ count: Math.round(minutes / 60) }); if (minutes < 1440) return m.comment_time_hours({ count: Math.round(minutes / 60) });
return m.comment_time_days({ count: Math.round(minutes / 1440) }); return m.comment_time_days({ count: Math.round(minutes / 1440) });
} }
// "vor N Jahren" for a historical letter date relative to now. Computed from
// calendar fields (not a constant ms-per-year) so that a letter from exactly
// one year ago reports "vor 1 Jahr" rather than falling on the wrong side of
// a leap-year rounding. Returns "" for invalid or future dates — the caller
// should then hide the relative-time label rather than render a misleading
// "vor 0 Jahren".
export function relativeYearsDe(from: Date, now: Date = new Date()): string {
if (Number.isNaN(from.getTime()) || Number.isNaN(now.getTime())) return '';
if (from.getTime() > now.getTime()) return '';
let years = now.getUTCFullYear() - from.getUTCFullYear();
const beforeAnniversary =
now.getUTCMonth() < from.getUTCMonth() ||
(now.getUTCMonth() === from.getUTCMonth() && now.getUTCDate() < from.getUTCDate());
if (beforeAnniversary) years -= 1;
if (years < 1) return 'vor weniger als 1 Jahr';
if (years === 1) return 'vor 1 Jahr';
return `vor ${years} Jahren`;
}

View File

@@ -0,0 +1,76 @@
import { afterEach, describe, expect, it } from 'vitest';
import { bulkSelectionStore } from './bulkSelection.svelte';
describe('bulkSelectionStore', () => {
afterEach(() => bulkSelectionStore.clear());
it('starts empty', () => {
expect(bulkSelectionStore.size).toBe(0);
});
it('toggle adds an id when absent', () => {
bulkSelectionStore.toggle('a');
expect(bulkSelectionStore.has('a')).toBe(true);
expect(bulkSelectionStore.size).toBe(1);
});
it('toggle removes an id when present', () => {
bulkSelectionStore.add('a');
bulkSelectionStore.toggle('a');
expect(bulkSelectionStore.has('a')).toBe(false);
});
it('add and remove update size', () => {
bulkSelectionStore.add('a');
bulkSelectionStore.add('b');
expect(bulkSelectionStore.size).toBe(2);
bulkSelectionStore.remove('a');
expect(bulkSelectionStore.size).toBe(1);
expect(bulkSelectionStore.has('b')).toBe(true);
});
it('add is idempotent', () => {
bulkSelectionStore.add('a');
bulkSelectionStore.add('a');
expect(bulkSelectionStore.size).toBe(1);
});
it('setAll replaces the selection', () => {
bulkSelectionStore.add('a');
bulkSelectionStore.add('b');
bulkSelectionStore.setAll(['c', 'd', 'e']);
expect(bulkSelectionStore.size).toBe(3);
expect(bulkSelectionStore.has('a')).toBe(false);
expect(bulkSelectionStore.has('c')).toBe(true);
});
it('clear empties the selection', () => {
bulkSelectionStore.add('a');
bulkSelectionStore.add('b');
bulkSelectionStore.clear();
expect(bulkSelectionStore.size).toBe(0);
});
it('setAll([]) leaves the store empty (no-op when filter returned zero matches)', () => {
bulkSelectionStore.add('a');
bulkSelectionStore.setAll([]);
expect(bulkSelectionStore.size).toBe(0);
});
it('setAll drops all previously selected ids, not just some', () => {
bulkSelectionStore.add('a');
bulkSelectionStore.add('b');
bulkSelectionStore.setAll(['c', 'd']);
expect(bulkSelectionStore.has('a')).toBe(false);
expect(bulkSelectionStore.has('b')).toBe(false);
expect(bulkSelectionStore.has('c')).toBe(true);
expect(bulkSelectionStore.has('d')).toBe(true);
});
it('ids getter exposes the current SvelteSet', () => {
bulkSelectionStore.add('a');
bulkSelectionStore.add('b');
const ids = Array.from(bulkSelectionStore.ids);
expect(ids.sort()).toEqual(['a', 'b']);
});
});

View File

@@ -0,0 +1,36 @@
import { SvelteSet } from 'svelte/reactivity';
// Live accumulator. Selection persists across pagination and route changes
// within /documents and /enrich. Cleared on successful bulk save or via
// "Alles aufheben". The store is module-singleton — there is only ever one
// bulk-edit selection per browser session.
const selectedIds = new SvelteSet<string>();
export const bulkSelectionStore = {
get ids(): SvelteSet<string> {
return selectedIds;
},
get size(): number {
return selectedIds.size;
},
has(id: string): boolean {
return selectedIds.has(id);
},
toggle(id: string): void {
if (selectedIds.has(id)) selectedIds.delete(id);
else selectedIds.add(id);
},
add(id: string): void {
selectedIds.add(id);
},
remove(id: string): void {
selectedIds.delete(id);
},
setAll(ids: Iterable<string>): void {
selectedIds.clear();
for (const id of ids) selectedIds.add(id);
},
clear(): void {
selectedIds.clear();
}
};

View File

@@ -1,37 +0,0 @@
import { describe, expect, it } from 'vitest';
import { thumbnailUrl } from './thumbnails';
describe('thumbnailUrl', () => {
it('returns null when thumbnailKey is undefined', () => {
expect(thumbnailUrl({ id: 'abc' })).toBeNull();
});
it('returns url without version param when thumbnailKey present but generatedAt missing', () => {
expect(thumbnailUrl({ id: 'abc', thumbnailKey: 'thumbnails/abc.jpg' })).toBe(
'/api/documents/abc/thumbnail'
);
});
it('appends encoded cache-bust param when generatedAt present', () => {
const url = thumbnailUrl({
id: 'abc',
thumbnailKey: 'thumbnails/abc.jpg',
thumbnailGeneratedAt: '2026-04-22T20:41:15.123456'
});
expect(url).toBe('/api/documents/abc/thumbnail?v=2026-04-22T20%3A41%3A15.123456');
});
it('different generatedAt produces different URL — enables cache-bust on file replace', () => {
const a = thumbnailUrl({
id: 'x',
thumbnailKey: 'thumbnails/x.jpg',
thumbnailGeneratedAt: '2026-01-01T10:00:00'
});
const b = thumbnailUrl({
id: 'x',
thumbnailKey: 'thumbnails/x.jpg',
thumbnailGeneratedAt: '2026-01-01T11:00:00'
});
expect(a).not.toBe(b);
});
});

View File

@@ -1,18 +0,0 @@
type ThumbnailDoc = {
id: string;
thumbnailKey?: string;
thumbnailGeneratedAt?: string;
};
/**
* Builds the URL for a document thumbnail image, or returns null when the document
* has no thumbnail yet. When `thumbnailGeneratedAt` is present it is appended as a
* `?v=…` query param so the browser / proxy cache is invalidated whenever the file
* is replaced (the backend regenerates thumbnails at the same S3 key on replace).
*/
export function thumbnailUrl(doc: ThumbnailDoc): string | null {
if (!doc.thumbnailKey) return null;
const base = `/api/documents/${doc.id}/thumbnail`;
if (!doc.thumbnailGeneratedAt) return base;
return `${base}?v=${encodeURIComponent(doc.thumbnailGeneratedAt)}`;
}

View File

@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { parseFilename, stripExtension } from './filename'; import { parseFilename, stripExtension, bulkTitleFromFilename } from './filename';
describe('parseFilename', () => { describe('parseFilename', () => {
describe('date-first patterns', () => { describe('date-first patterns', () => {
@@ -86,6 +86,24 @@ describe('parseFilename', () => {
}); });
}); });
describe('bulkTitleFromFilename', () => {
it('replaces underscores with spaces', () => {
expect(bulkTitleFromFilename('hello_world.pdf')).toBe('hello world');
});
it('replaces hyphens with spaces', () => {
expect(bulkTitleFromFilename('2024-01-01_Max.pdf')).toBe('2024 01 01 Max');
});
it('collapses multiple separators', () => {
expect(bulkTitleFromFilename('foo__bar--baz.pdf')).toBe('foo bar baz');
});
it('strips extension', () => {
expect(bulkTitleFromFilename('document.pdf')).toBe('document');
});
});
describe('stripExtension', () => { describe('stripExtension', () => {
it('removes the extension', () => { it('removes the extension', () => {
expect(stripExtension('document.pdf')).toBe('document'); expect(stripExtension('document.pdf')).toBe('document');

View File

@@ -81,3 +81,7 @@ export function parseFilename(filename: string): FilenameParseResult {
export function stripExtension(filename: string): string { export function stripExtension(filename: string): string {
return filename.replace(/\.[^/.]+$/, ''); return filename.replace(/\.[^/.]+$/, '');
} }
export function bulkTitleFromFilename(filename: string): string {
return stripExtension(filename).replace(/[_-]+/g, ' ').trim();
}

Some files were not shown because too many files have changed in this diff Show More