Compare commits

...

83 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
67 changed files with 4767 additions and 339 deletions

View File

@@ -3,6 +3,7 @@ package org.raddatz.familienarchiv.controller;
import java.io.IOException;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@@ -13,11 +14,18 @@ import java.util.UUID;
import io.swagger.v3.oas.annotations.Parameter;
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.DocumentUpdateDTO;
import org.raddatz.familienarchiv.dto.TagOperator;
@@ -193,6 +201,7 @@ public class DocumentController {
@RequirePermission(Permission.WRITE_ALL)
public QuickUploadResult quickUpload(
@RequestPart(value = "files", required = false) List<MultipartFile> files,
@RequestPart(value = "metadata", required = false) DocumentBatchMetadataDTO metadata,
Authentication authentication) {
List<Document> created = new ArrayList<>();
List<Document> updated = new ArrayList<>();
@@ -202,14 +211,21 @@ public class DocumentController {
return new QuickUploadResult(created, updated, errors);
}
documentService.validateBatch(files.size(), metadata);
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())) {
errors.add(new UploadError(file.getOriginalFilename(), "UNSUPPORTED_FILE_TYPE"));
continue;
}
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()) {
created.add(result.document());
} else {
@@ -221,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);
}
// --- 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")
@RequirePermission(Permission.WRITE_ALL)
public Map<String, Long> getIncompleteCount() {

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

@@ -109,6 +109,10 @@ public enum ErrorCode {
// --- Generic ---
/** Request validation failed (missing or malformed fields). 400 */
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 */
INTERNAL_ERROR,
}

View File

@@ -7,6 +7,9 @@ import org.raddatz.familienarchiv.audit.ActivityActorDTO;
import org.raddatz.familienarchiv.audit.AuditKind;
import org.raddatz.familienarchiv.audit.AuditLogQueryService;
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.DocumentSearchResult;
import org.raddatz.familienarchiv.dto.DocumentSort;
@@ -132,6 +135,52 @@ public class DocumentService {
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
public Document createDocument(DocumentUpdateDTO dto, MultipartFile file) throws IOException {
String filename = (file != null && !file.isEmpty())
@@ -287,20 +336,143 @@ public class DocumentService {
public Document updateDocumentTags(UUID docId, List<String> tagNames) {
Document doc = documentRepository.findById(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) {
// Clean the string
String cleanName = name.trim();
if (cleanName.isEmpty())
continue;
if (cleanName.isEmpty()) 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);
return documentRepository.save(doc);
Specification<Document> spec = buildSearchSpec(
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;
}
/**
@@ -366,17 +538,8 @@ public class DocumentService {
if (rankedIds.isEmpty()) return DocumentSearchResult.of(List.of());
}
boolean useOrLogic = tagOperator == TagOperator.OR;
List<Set<UUID>> expandedTagSets = tagService.expandTagNamesToDescendantIdSets(tags);
Specification<Document> textSpec = hasText ? hasIds(rankedIds) : (root, query, cb) -> null;
Specification<Document> spec = Specification.where(textSpec)
.and(isBetween(from, to))
.and(hasSender(sender))
.and(hasReceiver(receiver))
.and(hasTags(expandedTagSets, useOrLogic))
.and(hasTagPartial(tagQ))
.and(hasStatus(status));
Specification<Document> spec = buildSearchSpec(
hasText, rankedIds, from, to, sender, receiver, tags, tagQ, status, tagOperator);
// SENDER, RECEIVER and RELEVANCE sorts load the full match set and slice in memory.
// JPA's Sort.by("sender.lastName") generates an INNER JOIN that silently drops

View File

@@ -23,7 +23,8 @@ spring:
servlet:
multipart:
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:
host: ${MAIL_HOST:}

View File

@@ -1,15 +1,17 @@
package org.raddatz.familienarchiv.controller;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO;
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
import org.raddatz.familienarchiv.dto.DocumentVersionSummary;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentStatus;
import org.raddatz.familienarchiv.model.DocumentVersion;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.security.PermissionAspect;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
import org.raddatz.familienarchiv.service.DocumentService;
import org.raddatz.familienarchiv.service.DocumentVersionService;
@@ -766,4 +768,476 @@ class DocumentControllerTest {
.andExpect(status().isOk())
.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

@@ -16,6 +16,7 @@ import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO;
import org.raddatz.familienarchiv.dto.MatchOffset;
import org.raddatz.familienarchiv.dto.SearchMatchData;
import org.raddatz.familienarchiv.dto.TagOperator;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentStatus;
@@ -1813,4 +1814,437 @@ class DocumentServiceTest {
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

@@ -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

@@ -8,21 +8,27 @@ test.describe('Help chip — Read/Edit panel header', () => {
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();
// Find and click the (?) help chip
const helpBtn = page.locator('button[aria-expanded]');
// 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
await expect(page.locator('[role="tooltip"]')).toBeVisible();
// 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.locator('[role="tooltip"]')).not.toBeVisible();
await expect(
page.getByRole('region', { name: 'Lese- und Bearbeitungsmodus' })
).not.toBeVisible();
// Focus should have returned to the chip
await expect(helpBtn).toBeFocused();

View File

@@ -1,10 +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 res = await request.post('/api/documents', {
const createRes = await request.post('/api/documents', {
multipart: { title: 'E2E Transcribe Coach Test' }
});
if (!res.ok()) throw new Error(`Create document failed: ${res.status()}`);
const doc = await res.json();
return doc.id as string;
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

@@ -63,6 +63,12 @@ test.describe('Richtlinien page — print media', () => {
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

@@ -2,6 +2,24 @@ 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']);
}
@@ -13,10 +31,13 @@ test.describe('Transcribe coach — empty state', () => {
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.emulateMedia({ reducedMotion: 'reduce' });
await page.goto(`/documents/${docId}`);
await page.getByRole('button', { name: 'Transkribieren' }).click();
@@ -31,14 +52,12 @@ test.describe('Transcribe coach — empty state', () => {
});
test('training footer is NOT visible on empty doc', async ({ page }) => {
await page.emulateMedia({ reducedMotion: 'reduce' });
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.emulateMedia({ reducedMotion: 'reduce' });
await page.goto(`/documents/${docId}`);
await page.getByRole('button', { name: 'Transkribieren' }).click();
await expect(page.getByRole('heading', { level: 2, name: /Erste Transkription/ })).toBeVisible({
@@ -50,10 +69,9 @@ test.describe('Transcribe coach — empty state', () => {
});
test('axe: panel empty state — dark theme — no WCAG 2.1 AA violations', async ({ page }) => {
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.goto(`/documents/${docId}`);
// Toggle dark theme
await page.getByRole('button', { name: /Farbmodus|theme/i }).click();
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
@@ -63,3 +81,25 @@ test.describe('Transcribe coach — empty state', () => {
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

@@ -515,7 +515,6 @@
"scan_collapse": "Scan verkleinern",
"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_draw_hint": "Zeichnen Sie Bereiche auf dem Dokument, um mit der Transkription zu beginnen.",
"transcription_panel_close": "Panel schließen",
"person_alias_heading": "Namensverlauf",
"person_alias_empty": "Noch keine Namensaenderungen erfasst.",
@@ -828,9 +827,9 @@
"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 ob Tante Hedwig oder Cousin Paul tippt — hier unsere Regeln. Die Seite wächst mit: sobald wir eine neue Konvention beschließen, landet sie hier.",
"richtlinien_wiki_text": "Das vollständige Kurrent- und Sütterlin-Alphabet brauchen Sie für diese Seite nicht — das erledigt Wikipedia. Hier sind unsere eigenen Regeln für das, was Wikipedia nicht beantwortet.",
"richtlinien_wiki_link": "Wikipedia",
"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.",
@@ -850,5 +849,54 @@
"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."
"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

@@ -515,7 +515,6 @@
"scan_collapse": "Collapse scan",
"transcription_empty_title": "No transcription yet",
"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",
"person_alias_heading": "Name history",
"person_alias_empty": "No name changes recorded yet.",
@@ -828,9 +827,9 @@
"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 — whether Tante Hedwig or Cousin Paul is typing — here are our rules. The page grows with us: as soon as we agree a new convention, it lands here.",
"richtlinien_wiki_text": "You don't need the full Kurrent and Sütterlin alphabet on this page — that's what Wikipedia is for. Here are our own rules for everything Wikipedia can't answer.",
"richtlinien_wiki_link": "Wikipedia",
"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.",
@@ -850,5 +849,54 @@
"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."
"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

@@ -515,7 +515,6 @@
"scan_collapse": "Reducir escaneo",
"transcription_empty_title": "Sin 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",
"person_alias_heading": "Historial de nombres",
"person_alias_empty": "Aun no se han registrado cambios de nombre.",
@@ -828,9 +827,9 @@
"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 — ya sea la tía Hedwig o el primo Paul quien escriba — aquí están nuestras reglas. La página crece con nosotros.",
"richtlinien_wiki_text": "No necesitas el alfabeto Kurrent completo aquí — eso lo hace Wikipedia. Aquí están nuestras propias reglas para lo que Wikipedia no responde.",
"richtlinien_wiki_link": "Wikipedia",
"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.",
@@ -850,5 +849,54 @@
"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."
"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

@@ -4,13 +4,14 @@ import type { components } from '$lib/generated/api';
import { applyOffsets } from '$lib/search';
import { formatDate } from '$lib/utils/date';
import * as m from '$lib/paraglide/messages.js';
import { bulkSelectionStore } from '$lib/stores/bulkSelection.svelte';
import ProgressRing from './ProgressRing.svelte';
import ContributorStack from './ContributorStack.svelte';
import DocumentThumbnail from './DocumentThumbnail.svelte';
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 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>
<div class="pointer-events-none relative z-10 px-4 py-4 sm:py-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 -->
<DocumentThumbnail doc={doc} size="lg" />

View File

@@ -3,6 +3,7 @@ import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import { goto } from '$app/navigation';
import DocumentRow from './DocumentRow.svelte';
import { bulkSelectionStore } from '$lib/stores/bulkSelection.svelte';
import type { components } from '$lib/generated/api';
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
@@ -10,6 +11,7 @@ vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
afterEach(() => {
cleanup();
vi.mocked(goto).mockClear();
bulkSelectionStore.clear();
});
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 ─────────────────────────────────────────
describe('DocumentRow progress ring and contributors', () => {

View File

@@ -1,3 +1,10 @@
<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';
@@ -11,8 +18,9 @@ type Props = {
let { label, placement = 'bottom', children }: Props = $props();
const popoverId = `help-popover-${_counter++}`;
let open = $state(false);
const popoverId = `help-popover-${Math.random().toString(36).slice(2)}`;
let triggerEl: HTMLButtonElement | null = $state(null);
function toggle() {
@@ -58,6 +66,10 @@ const placementClass: Record<Placement, string> = {
</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"
@@ -65,15 +77,20 @@ const placementClass: Record<Placement, string> = {
aria-expanded={open}
aria-controls={popoverId}
onclick={toggle}
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 hover:border-brand-navy hover:text-brand-navy"
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="tooltip"
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}

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, afterEach, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import { page, userEvent } from 'vitest/browser';
import HelpPopover from './HelpPopover.svelte';
afterEach(cleanup);
@@ -20,7 +20,7 @@ describe('HelpPopover — initial state', () => {
renderPopover();
const btn = page.getByRole('button', { name: /Help/ });
await expect.element(btn).toHaveAttribute('aria-expanded', 'false');
expect(document.querySelector('[role="tooltip"]')).toBeNull();
expect(document.querySelector('[role="region"]')).toBeNull();
});
});
@@ -30,37 +30,61 @@ describe('HelpPopover — open / close interactions', () => {
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="tooltip"]')).not.toBeNull();
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="tooltip"]')).not.toBeNull();
expect(document.querySelector('[role="region"]')).not.toBeNull();
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await vi.waitFor(() => expect(document.querySelector('[role="tooltip"]')).toBeNull());
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="tooltip"]')).not.toBeNull();
expect(document.querySelector('[role="region"]')).not.toBeNull();
document.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }));
await vi.waitFor(() => expect(document.querySelector('[role="tooltip"]')).toBeNull());
await vi.waitFor(() => expect(document.querySelector('[role="region"]')).toBeNull());
});
it('opens on Enter key (button is keyboard-reachable, Enter fires click)', async () => {
it('opens on Enter key', async () => {
renderPopover();
await page.getByRole('button', { name: /Help/ }).click();
expect(document.querySelector('[role="tooltip"]')).not.toBeNull();
(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 (button is keyboard-reachable, Space fires click)', async () => {
it('opens on Space key', async () => {
renderPopover();
await page.getByRole('button', { name: /Help/ }).click();
expect(document.querySelector('[role="tooltip"]')).not.toBeNull();
(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');
});
});
@@ -74,4 +98,17 @@ describe('HelpPopover — aria wiring', () => {
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

@@ -67,7 +67,7 @@ function removePerson(id: string | undefined) {
<div class="relative" use:clickOutside onclickoutside={() => (showDropdown = false)}>
<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)}
<span

View File

@@ -4,6 +4,7 @@ import type { components } from '$lib/generated/api';
import { m } from '$lib/paraglide/messages.js';
import { clickOutside } from '$lib/actions/clickOutside';
import { createTypeahead } from '$lib/hooks/useTypeahead.svelte';
import FieldLabelBadge from './document/FieldLabelBadge.svelte';
type Person = components['schemas']['Person'];
interface Props {
@@ -18,6 +19,7 @@ interface Props {
autofocus?: boolean;
required?: boolean;
restrictToCorrespondentsOf?: string;
badge?: 'additive' | 'replace';
onchange?: (value: string) => void;
onfocused?: () => void;
}
@@ -34,6 +36,7 @@ let {
autofocus = false,
required = false,
restrictToCorrespondentsOf,
badge,
onchange,
onfocused
}: Props = $props();
@@ -116,7 +119,7 @@ function selectPerson(person: Person) {
class={compact
? 'block text-xs font-bold tracking-wide text-ink-3 uppercase'
: '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} />
@@ -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'
: 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 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)}

View File

@@ -3,11 +3,21 @@ type Props = {
icon: string;
title: string;
body: string;
beispielInput?: string;
beispielInputStrike?: boolean;
beispielOutput?: string;
beispielLabel?: string;
};
let { icon, title, body, beispielOutput, beispielLabel = 'Beispiel' }: Props = $props();
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">
@@ -18,12 +28,18 @@ let { icon, title, body, beispielOutput, beispielLabel = 'Beispiel' }: Props = $
<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-[#FAF8F1] px-4 py-3">
<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">
<code class="font-mono">{beispielOutput}</code>
{#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}

View File

@@ -13,7 +13,7 @@ import TranscribeDragDemo from './TranscribeDragDemo.svelte';
<ol class="m-0 flex list-none flex-col gap-[18px] p-0">
<!-- Step 1 -->
<li class="grid gap-3.5" style="grid-template-columns: 34px 1fr; align-items: start;">
<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"
@@ -27,7 +27,7 @@ import TranscribeDragDemo from './TranscribeDragDemo.svelte';
</li>
<!-- Step 2 -->
<li class="grid gap-3.5" style="grid-template-columns: 34px 1fr; align-items: start;">
<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"
@@ -40,7 +40,7 @@ import TranscribeDragDemo from './TranscribeDragDemo.svelte';
</li>
<!-- Step 3 -->
<li class="grid gap-3.5" style="grid-template-columns: 34px 1fr; align-items: start;">
<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"

View File

@@ -1,7 +1,19 @@
<script lang="ts">
const prefersReducedMotion = $derived(
// $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}
@@ -10,7 +22,7 @@ const prefersReducedMotion = $derived(
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-[#FAF8F1]"
class="border-brand-sand block w-full rounded-sm border bg-parchment"
>
<g
stroke="#2a2a2a"
@@ -61,7 +73,7 @@ const prefersReducedMotion = $derived(
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-[#FAF8F1]"
class="border-brand-sand block w-full rounded-sm border bg-parchment"
>
<!-- Kurrent writing (static) -->
<g

View File

@@ -177,6 +177,6 @@ describe('TranscriptionPanelHeader', () => {
const helpBtn = document.querySelector('button[aria-expanded]') as HTMLButtonElement;
helpBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await vi.waitFor(() => expect(document.querySelector('[role="tooltip"]')).not.toBeNull());
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">
import { untrack } from 'svelte';
import { onMount } from 'svelte';
import TagInput, { type Tag } from '$lib/components/TagInput.svelte';
import FieldLabelBadge from './FieldLabelBadge.svelte';
import { m } from '$lib/paraglide/messages.js';
let {
tags = $bindable<Tag[]>([]),
currentTitle = $bindable(''),
documentLocation = $bindable(''),
archiveBox = $bindable(''),
archiveFolder = $bindable(''),
initialTitle = '',
initialDocumentLocation = '',
initialSummary = '',
titleRequired = false,
suggestedTitle = '',
hideTitle = false
hideTitle = false,
editMode = false
}: {
tags?: Tag[];
currentTitle?: string;
documentLocation?: string;
archiveBox?: string;
archiveFolder?: string;
initialTitle?: string;
initialDocumentLocation?: string;
initialSummary?: string;
titleRequired?: boolean;
suggestedTitle?: string;
hideTitle?: boolean;
editMode?: boolean;
} = $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);
currentTitle = untrack(() => initialTitle);
onMount(() => {
if (!currentTitle && initialTitle) currentTitle = initialTitle;
if (!documentLocation && initialDocumentLocation) documentLocation = initialDocumentLocation;
});
const titleValue = $derived(titleDirty ? currentTitle : suggestedTitle || currentTitle);
</script>
@@ -67,40 +85,80 @@ const titleValue = $derived(titleDirty ? currentTitle : suggestedTitle || curren
<!-- Schlagworte (optional) -->
<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} />
<input type="hidden" name="tags" value={tags.map((t) => t.name).join(',')} />
</div>
<!-- Inhalt (optional) -->
<div>
<label for="summary" class="mb-1 block text-sm font-medium text-ink-2"
>{m.form_label_content()}</label
>
<textarea
id="summary"
name="summary"
rows="5"
placeholder={m.form_placeholder_content()}
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>
{#if !editMode}
<!-- Inhalt (optional) — not bulk-editable. -->
<div>
<label for="summary" class="mb-1 block text-sm font-medium text-ink-2"
>{m.form_label_content()}</label
>
<textarea
id="summary"
name="summary"
rows="5"
placeholder={m.form_placeholder_content()}
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>
{/if}
<!-- Aufbewahrungsort (optional) -->
<div>
<div data-testid="description-document-location">
<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
id="documentLocation"
type="text"
name="documentLocation"
value={initialDocumentLocation}
bind:value={documentLocation}
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"
/>
<p class="mt-1 text-xs text-ink-3">{m.form_helper_archive_location()}</p>
</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>

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

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'
| 'FORBIDDEN'
| 'VALIDATION_ERROR'
| 'BATCH_TOO_LARGE'
| 'BULK_EDIT_TOO_MANY_IDS'
| 'INTERNAL_ERROR';
export interface BackendError {
@@ -139,6 +141,10 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
return m.error_forbidden();
case '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:
return m.error_internal_error();
}

View File

@@ -484,6 +484,22 @@ export interface paths {
patch?: 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": {
parameters: {
query?: never;
@@ -548,6 +564,22 @@ export interface paths {
patch?: 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": {
parameters: {
query?: never;
@@ -660,6 +692,22 @@ export interface paths {
patch: operations["updateAnnotation"];
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": {
parameters: {
query?: never;
@@ -1028,6 +1076,22 @@ export interface paths {
patch?: 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": {
parameters: {
query?: never;
@@ -1124,6 +1188,22 @@ export interface paths {
patch?: 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": {
parameters: {
query?: never;
@@ -1204,6 +1284,22 @@ export interface paths {
patch?: 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": {
parameters: {
query?: never;
@@ -1390,7 +1486,6 @@ export interface components {
thumbnailAspect?: "PORTRAIT" | "LANDSCAPE";
/** Format: int32 */
pageCount?: number;
thumbnailUrl?: string;
originalFilename: string;
/** @enum {string} */
status: "PLACEHOLDER" | "UPLOADED" | "TRANSCRIBED" | "REVIEWED" | "ARCHIVED";
@@ -1413,6 +1508,7 @@ export interface components {
sender?: components["schemas"]["Person"];
tags?: components["schemas"]["Tag"][];
trainingLabels?: ("KURRENT_RECOGNITION" | "KURRENT_SEGMENTATION")[];
thumbnailUrl?: string;
};
UpdateTranscriptionBlockDTO: {
text?: string;
@@ -1639,6 +1735,17 @@ export interface components {
/** Format: date-time */
createdAt: string;
};
DocumentBatchMetadataDTO: {
titles?: string[];
/** Format: uuid */
senderId?: string;
receiverIds?: string[];
/** Format: date */
documentDate?: string;
location?: string;
tagNames?: string[];
metadataComplete?: boolean;
};
QuickUploadResult: {
created?: components["schemas"]["Document"][];
updated?: components["schemas"]["Document"][];
@@ -1648,6 +1755,15 @@ export interface components {
filename?: string;
code?: string;
};
BatchMetadataRequest: {
ids: string[];
};
DocumentBatchSummary: {
/** Format: uuid */
id: string;
title: string;
pdfUrl: string;
};
ResetPasswordRequest: {
token?: string;
newPassword?: string;
@@ -1673,6 +1789,21 @@ export interface components {
/** Format: date-time */
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: {
/** Format: int32 */
count: number;
@@ -1708,6 +1839,26 @@ export interface components {
/** Format: double */
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: {
/** Format: int64 */
segmentationCount: number;
@@ -1759,7 +1910,6 @@ export interface components {
/** Format: uuid */
id?: string;
displayName?: string;
personType?: string;
firstName?: string;
lastName?: string;
/** Format: int64 */
@@ -1770,6 +1920,7 @@ export interface components {
deathYear?: number;
alias?: string;
notes?: string;
personType?: string;
};
SenderModel: {
/** Format: uuid */
@@ -1837,10 +1988,10 @@ export interface components {
timeout?: number;
};
PageNotificationDTO: {
/** Format: int32 */
totalPages?: number;
/** Format: int64 */
totalElements?: number;
/** Format: int32 */
totalPages?: number;
pageable?: components["schemas"]["PageableObject"];
first?: boolean;
last?: boolean;
@@ -3152,6 +3303,7 @@ export interface operations {
content: {
"multipart/form-data": {
files?: string[];
metadata?: components["schemas"]["DocumentBatchMetadataDTO"];
};
};
};
@@ -3167,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: {
parameters: {
query?: never;
@@ -3255,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: {
parameters: {
query?: never;
@@ -3483,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: {
parameters: {
query?: {
@@ -3975,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: {
parameters: {
query?: never;
@@ -4038,15 +4280,9 @@ export interface operations {
dir?: string;
/** @description Tag operator: AND (default) or OR */
tagOp?: string;
/**
* @description Page number (0-indexed)
* @default 0
*/
/** @description Page number (0-indexed) */
page?: number;
/**
* @description Page size (max 100)
* @default 50
*/
/** @description Page size (max 100) */
size?: number;
};
header?: never;
@@ -4133,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: {
parameters: {
query: {
@@ -4245,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: {
parameters: {
query?: never;

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,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { parseFilename, stripExtension } from './filename';
import { parseFilename, stripExtension, bulkTitleFromFilename } from './filename';
describe('parseFilename', () => {
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', () => {
it('removes the extension', () => {
expect(stripExtension('document.pdf')).toBe('document');

View File

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

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import './layout.css';
import { page } from '$app/state';
import { onMount } from 'svelte';
import { onMount, untrack } from 'svelte';
import * as m from '$lib/paraglide/messages.js';
import LanguageSwitcher from '$lib/components/LanguageSwitcher.svelte';
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
@@ -10,6 +10,7 @@ import AppNav from './AppNav.svelte';
import UserMenu from './UserMenu.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import { provideConfirmService } from '$lib/services/confirm.svelte.js';
import { bulkSelectionStore } from '$lib/stores/bulkSelection.svelte';
let { children, data } = $props();
@@ -17,6 +18,27 @@ let { children, data } = $props();
// ConfirmDialog below reads it via getConfirmService() and renders the <dialog>.
provideConfirmService();
// Auto-clear the bulk-selection store when the user leaves the routes that
// surface the BulkSelectionBar. Without this the selection silently follows
// the user to /persons / /admin etc. and reappears as a stale 3-doc count
// when they wander back to /documents — Felix C4 on PR #331.
//
// `bulkSelectionStore.size` is read inside `untrack` so the effect only
// re-fires on route change, not on every checkbox toggle (Felix C3 cycle 3).
$effect(() => {
const path = page.url.pathname;
const inBulkContext =
path === '/documents' ||
path.startsWith('/documents/') ||
path === '/enrich' ||
path.startsWith('/enrich/');
if (!inBulkContext) {
untrack(() => {
if (bulkSelectionStore.size > 0) bulkSelectionStore.clear();
});
}
});
const isAdmin = $derived(
data?.user?.groups?.some((g: { permissions: string[] }) => g.permissions.includes('ADMIN'))
);

View File

@@ -119,7 +119,7 @@ function groupByReceiver(docItems: DocumentSearchItem[]) {
</div>
<ul class="divide-y divide-line">
{#each group.items as item (group.label + '-' + item.document.id)}
<DocumentRow item={item} />
<DocumentRow item={item} canWrite={canWrite} />
{/each}
</ul>
</div>

View File

@@ -6,6 +6,9 @@ import { SvelteURLSearchParams } from 'svelte/reactivity';
import SearchFilterBar from '../SearchFilterBar.svelte';
import DocumentList from '../DocumentList.svelte';
import Pagination from '$lib/components/Pagination.svelte';
import BulkSelectionBar from '$lib/components/document/BulkSelectionBar.svelte';
import { bulkSelectionStore } from '$lib/stores/bulkSelection.svelte';
import { getErrorMessage, parseBackendError } from '$lib/errors';
import * as m from '$lib/paraglide/messages.js';
let { data } = $props();
@@ -138,6 +141,50 @@ $effect(() => {
}
});
let editingAll = $state(false);
let editAllError = $state<string | null>(null);
/**
* Fast path: replace the current selection with every document matching the
* active filter (across all pages) and jump to the bulk-edit screen. The
* /api/documents/ids endpoint is hard-capped (5000 results); on cap overflow
* the backend returns BULK_EDIT_TOO_MANY_IDS, which we surface inline.
*/
async function editAllMatching() {
if (editingAll) return;
editingAll = true;
editAllError = null;
try {
const params = buildSearchParams({
q: data.q || '',
from: data.from || '',
to: data.to || '',
senderId: data.senderId || '',
receiverId: data.receiverId || '',
tags: data.tags || [],
sort: '',
dir: '',
tagQ: data.tagQ || '',
tagOp: (data.tagOp as 'AND' | 'OR') || 'AND'
});
params.delete('sort');
params.delete('dir');
const res = await fetch(`/api/documents/ids?${params.toString()}`);
if (!res.ok) {
const backend = await parseBackendError(res);
editAllError = getErrorMessage(backend?.code);
return;
}
const ids: string[] = await res.json();
bulkSelectionStore.setAll(ids);
await goto('/documents/bulk-edit');
} catch {
editAllError = m.bulk_edit_all_x_failed();
} finally {
editingAll = false;
}
}
// Keep local filter state in sync with server data after navigation completes.
// Guard q: skip overwrite while the user is actively typing.
$effect(() => {
@@ -159,7 +206,13 @@ $effect(() => {
<title>{m.nav_documents()} Familienarchiv</title>
</svelte:head>
<main class="mx-auto max-w-7xl px-4 py-8 font-sans sm:px-6 lg:px-8">
<!-- Reserve bottom padding when the bulk-selection bar is visible so the
sticky bar does not occlude the last document row or the pagination
controls (WCAG 1.4.10 / 2.4.7). -->
<main
class="mx-auto max-w-7xl px-4 py-8 font-sans sm:px-6 lg:px-8"
class:pb-32={bulkSelectionStore.size > 0 && data.canWrite}
>
<h1 class="sr-only">{m.nav_documents()}</h1>
<SearchFilterBar
@@ -181,6 +234,25 @@ $effect(() => {
onblur={() => (qFocused = false)}
/>
{#if data.canWrite && data.totalElements > 0}
<div class="mb-2 flex flex-col items-end gap-1">
<button
type="button"
onclick={editAllMatching}
disabled={editingAll}
class="inline-flex items-center gap-1 text-sm font-medium text-ink-2 transition-colors hover:text-ink disabled:opacity-50"
data-testid="bulk-edit-all-x"
>
{m.bulk_edit_all_x({ count: data.totalElements })}
</button>
{#if editAllError}
<p role="alert" class="text-xs text-danger" data-testid="bulk-edit-all-x-error">
{editAllError}
</p>
{/if}
</div>
{/if}
<DocumentList
items={data.items}
total={data.totalElements}
@@ -192,3 +264,5 @@ $effect(() => {
<Pagination page={data.pageNumber} totalPages={data.totalPages} makeHref={buildPageHref} />
</main>
<BulkSelectionBar canWrite={data.canWrite} />

View File

@@ -0,0 +1,12 @@
import { redirect } from '@sveltejs/kit';
export async function load({ locals }: { locals: App.Locals }) {
// Defensive: a UserGroup row with NULL permissions returns undefined here
// rather than throwing on .includes() — treat that as "not WRITE_ALL".
const canWrite =
locals.user?.groups?.some(
(g: { permissions?: string[] }) => g.permissions?.includes('WRITE_ALL') ?? false
) ?? false;
if (!canWrite) throw redirect(303, '/documents');
return { canWrite };
}

View File

@@ -0,0 +1,84 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { bulkSelectionStore } from '$lib/stores/bulkSelection.svelte';
import BulkDocumentEditLayout, {
type BulkEditEntry
} from '$lib/components/document/BulkDocumentEditLayout.svelte';
import { getErrorMessage, parseBackendError } from '$lib/errors';
import { m } from '$lib/paraglide/messages.js';
let entries = $state<BulkEditEntry[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
onMount(async () => {
const ids = Array.from(bulkSelectionStore.ids);
if (ids.length === 0) {
// Skip the loading flash on the empty-store redirect path — the user
// is bouncing back to /documents in the next tick.
loading = false;
await goto('/documents');
return;
}
try {
const res = await fetch('/api/documents/batch-metadata', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids })
});
if (!res.ok) {
const backend = await parseBackendError(res);
error = getErrorMessage(backend?.code);
loading = false;
return;
}
const summaries = (await res.json()) as BulkEditEntry[];
entries = summaries;
} catch {
error = getErrorMessage(undefined);
} finally {
loading = false;
}
});
</script>
<svelte:head>
<title>{m.bulk_edit_title()} Familienarchiv</title>
</svelte:head>
{#if loading}
<div
class="flex h-full items-center justify-center gap-3 p-12 text-sm text-ink-2"
role="status"
aria-live="polite"
data-testid="bulk-edit-loading"
>
<svg
class="h-5 w-5 animate-spin text-ink-3"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
aria-hidden="true"
>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<span>{m.bulk_edit_loading()}</span>
</div>
{:else if error}
<div
role="alert"
class="m-6 rounded-sm border border-danger/40 bg-danger/10 p-4 text-sm text-danger"
data-testid="bulk-edit-page-error"
>
{error}
</div>
{:else if entries.length > 0}
<BulkDocumentEditLayout mode="edit" initialEditEntries={entries} />
{/if}

View File

@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest';
import { load } from './+page.server';
describe('/documents/bulk-edit +page.server.ts', () => {
it('redirects to /documents when user lacks WRITE_ALL', async () => {
const locals = { user: { groups: [{ permissions: ['READ_ALL'] }] } };
try {
// @ts-expect-error — partial event shape sufficient for this guard
await load({ locals });
throw new Error('expected redirect to be thrown');
} catch (e) {
const err = e as { status?: number; location?: string };
expect(err.status).toBe(303);
expect(err.location).toBe('/documents');
}
});
it('redirects when user has no groups', async () => {
const locals = { user: { groups: [] } };
try {
// @ts-expect-error — partial event shape sufficient for this guard
await load({ locals });
throw new Error('expected redirect');
} catch (e) {
expect((e as { status?: number }).status).toBe(303);
}
});
it('redirects when no user is logged in', async () => {
const locals = {};
try {
// @ts-expect-error — partial event shape sufficient for this guard
await load({ locals });
throw new Error('expected redirect');
} catch (e) {
expect((e as { status?: number }).status).toBe(303);
}
});
it('returns canWrite=true for a WRITE_ALL user', async () => {
const locals = { user: { groups: [{ permissions: ['WRITE_ALL', 'READ_ALL'] }] } };
// @ts-expect-error — partial event shape sufficient for this guard
const result = await load({ locals });
expect(result).toEqual({ canWrite: true });
});
it('redirects when a group has no permissions array (defensive)', async () => {
// Sara C7 — a UserGroup row with NULL permissions used to throw on
// .includes(); the guard now treats that case as "not WRITE_ALL".
const locals = {
user: { groups: [{ permissions: undefined as unknown as string[] }] }
};
try {
// @ts-expect-error — partial event shape sufficient for this guard
await load({ locals });
throw new Error('expected redirect');
} catch (e) {
expect((e as { status?: number }).status).toBe(303);
}
});
});

View File

@@ -1,154 +1,11 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { untrack } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
import WhoWhenSection from '$lib/components/document/WhoWhenSection.svelte';
import DescriptionSection from '$lib/components/document/DescriptionSection.svelte';
import TranscriptionSection from '$lib/components/document/TranscriptionSection.svelte';
import FileSectionNew from './FileSectionNew.svelte';
import { type FilenameParseResult } from '$lib/utils/filename';
import BulkDocumentEditLayout from '$lib/components/document/BulkDocumentEditLayout.svelte';
let { data, form } = $props();
let tags: { name: string; id?: string; color?: string; parentId?: string }[] = $state([]);
let senderId = $state(untrack(() => data.initialSenderId));
let selectedReceivers: { id: string; firstName?: string; lastName: string; displayName: string }[] =
$state(untrack(() => data.initialReceivers));
let parsedSuggestion = $state<FilenameParseResult>({});
// Title is derived from the filename suggestion unless the user has typed something
let titleDirty = $state(false);
let titleOverride = $state('');
let titleValue = $derived(
titleDirty ? titleOverride : (parsedSuggestion.suggestedTitle ?? titleOverride)
);
// Details panel: starts open when prefill data is present or a form error occurred.
// Auto-opens when filename parsing finds a date/sender, but never force-closes — user
// can always collapse the section manually.
let detailsOpen = $state(
!!(
untrack(() => data.initialSenderId) ||
untrack(() => data.initialReceivers).length > 0 ||
untrack(() => form)?.error
)
);
$effect(() => {
if (parsedSuggestion.dateIso || senderId || selectedReceivers.length > 0) {
detailsOpen = true;
}
});
let { data } = $props();
</script>
<div class="mx-auto max-w-4xl px-4 py-8">
<!-- Heading -->
<div class="mb-6">
<a
href="/"
class="group mb-4 inline-flex items-center text-xs font-bold tracking-widest text-ink-2 uppercase transition-colors hover:text-ink"
>
<svg
class="mr-2 h-4 w-4 transform transition-transform group-hover:-translate-x-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<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>
<h1 class="font-serif text-3xl text-ink">{m.doc_new_heading()}</h1>
</div>
{#if form?.error}
<div class="mb-6 rounded border border-red-200 bg-red-50 p-4 text-red-700">{form.error}</div>
{/if}
<form method="POST" enctype="multipart/form-data" use:enhance class="space-y-6 pb-20">
<!-- File upload — prominent, at the top -->
<FileSectionNew onfileParsed={(r) => (parsedSuggestion = r)} />
<!-- Standalone title card -->
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
<label for="new-title" class="mb-1 block text-sm font-medium text-ink-2"
>{m.form_label_title()}</label
>
<input
id="new-title"
type="text"
name="title"
value={titleValue}
oninput={(e) => {
titleOverride = (e.target as HTMLInputElement).value;
titleDirty = true;
}}
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"
placeholder="Titel eingeben…"
/>
</div>
<!-- Collapsible further details -->
<details
bind:open={detailsOpen}
class="group rounded-sm border border-line bg-surface shadow-sm"
>
<summary class="cursor-pointer list-none px-6 py-4">
<span class="text-xs font-bold tracking-widest text-ink-3 uppercase"
>{m.doc_more_details()}</span
>
</summary>
<div class="space-y-6 px-0 pb-6">
<WhoWhenSection
bind:senderId={senderId}
bind:selectedReceivers={selectedReceivers}
initialSenderName={data.initialSenderName}
suggestedDateIso={parsedSuggestion.dateIso ?? ''}
suggestedSenderName={parsedSuggestion.personName ?? ''}
/>
<DescriptionSection bind:tags={tags} hideTitle={true} />
<TranscriptionSection />
</div>
</details>
<!-- Sticky Save Bar -->
<div
class="sticky bottom-0 z-10 -mx-4 border-t border-line bg-surface px-4 py-3 shadow-[0_-2px_8px_rgba(0,0,0,0.06)] sm:px-6 sm:py-4"
>
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<a
href="/"
class="order-last text-center text-sm font-medium text-ink-2 transition-colors hover:text-ink sm:order-first sm:text-left"
>
{m.btn_cancel()}
</a>
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
<button
type="submit"
name="metadataComplete"
value="false"
formaction="?/save"
class="w-full rounded-sm border border-line px-5 py-2.5 font-sans text-xs font-bold tracking-widest text-ink-2 uppercase transition-colors hover:bg-muted sm:w-auto sm:py-2"
>
{m.btn_save()}
</button>
<button
type="submit"
name="metadataComplete"
value="true"
formaction="?/saveReviewed"
class="w-full rounded-sm bg-primary px-5 py-2.5 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-colors hover:bg-primary/90 sm:w-auto sm:py-2"
>
{m.btn_save_and_mark_reviewed()}
</button>
</div>
</div>
</div>
</form>
</div>
<BulkDocumentEditLayout
initialSenderId={data.initialSenderId}
initialSenderName={data.initialSenderName}
initialReceivers={data.initialReceivers}
/>

View File

@@ -21,15 +21,14 @@ const baseData = {
describe('New document page sender prefill', () => {
it('shows an empty sender input when no senderId is in the URL', async () => {
render(Page, { data: baseData, form: null });
render(Page, { data: baseData });
const input = document.querySelector<HTMLInputElement>('#senderId-search');
expect(input?.value).toBe('');
});
it('shows the sender name in the typeahead input when initialSenderName is set', async () => {
render(Page, {
data: { ...baseData, initialSenderId: 'p1', initialSenderName: 'Hans Müller' },
form: null
data: { ...baseData, initialSenderId: 'p1', initialSenderName: 'Hans Müller' }
});
const input = document.querySelector<HTMLInputElement>('#senderId-search');
expect(input?.value).toBe('Hans Müller');
@@ -37,8 +36,7 @@ describe('New document page sender prefill', () => {
it('sets the hidden senderId input to the prefilled ID', async () => {
render(Page, {
data: { ...baseData, initialSenderId: 'p1', initialSenderName: 'Hans Müller' },
form: null
data: { ...baseData, initialSenderId: 'p1', initialSenderName: 'Hans Müller' }
});
const hidden = document.querySelector<HTMLInputElement>(
'input[type="hidden"][name="senderId"]'
@@ -51,7 +49,7 @@ describe('New document page sender prefill', () => {
describe('New document page receiver prefill', () => {
it('shows no receiver chips when initialReceivers is empty', async () => {
render(Page, { data: baseData, form: null });
render(Page, { data: baseData });
await expect.element(page.getByText('Anna Schmidt')).not.toBeInTheDocument();
});
@@ -62,7 +60,7 @@ describe('New document page receiver prefill', () => {
{ id: 'p2', firstName: 'Anna', lastName: 'Schmidt', displayName: 'Anna Schmidt' }
]
};
render(Page, { data, form: null });
render(Page, { data });
await expect.element(page.getByText('Anna Schmidt')).toBeInTheDocument();
});
@@ -73,7 +71,7 @@ describe('New document page receiver prefill', () => {
{ id: 'p2', firstName: 'Anna', lastName: 'Schmidt', displayName: 'Anna Schmidt' }
]
};
render(Page, { data, form: null });
render(Page, { data });
const hidden = document.querySelector<HTMLInputElement>('input[name="receiverIds"]');
expect(hidden?.value).toBe('p2');
});

View File

@@ -19,5 +19,5 @@ export async function load({
const documents = result.response.ok ? (result.data ?? []) : [];
return { documents };
return { documents, canWrite };
}

View File

@@ -1,14 +1,19 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import BackButton from '$lib/components/BackButton.svelte';
import BulkSelectionBar from '$lib/components/document/BulkSelectionBar.svelte';
import { bulkSelectionStore } from '$lib/stores/bulkSelection.svelte';
let { data } = $props();
const documents = $derived(data.documents);
const count = $derived(documents.length);
const canWrite = $derived(data.canWrite);
</script>
<div class="mx-auto max-w-4xl px-4 py-10">
<!-- Reserve bottom padding when the bulk-selection bar is visible so the
sticky bar does not occlude the last document row (WCAG 1.4.10). -->
<div class="mx-auto max-w-4xl px-4 py-10" class:pb-32={bulkSelectionStore.size > 0 && canWrite}>
<!-- Back Link -->
<BackButton />
@@ -61,8 +66,24 @@ const count = $derived(documents.length);
<div class="border border-line bg-surface shadow-sm">
<ul class="divide-y divide-line-2">
{#each documents as doc (doc.id)}
<li class="group transition-colors duration-200 hover:bg-muted">
<a href="/enrich/{doc.id}" class="flex items-center justify-between p-6">
<li class="group relative transition-colors duration-200 hover:bg-muted">
<a href="/enrich/{doc.id}" class="absolute inset-0 z-0 block" aria-label={doc.title}
></a>
<div class="pointer-events-none relative z-10 flex items-center justify-between p-6">
{#if canWrite}
<label
class="pointer-events-auto mr-4 flex min-h-[44px] min-w-[44px] flex-shrink-0 cursor-pointer items-center"
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: doc.title })}
/>
</label>
{/if}
<div class="min-w-0 flex-1">
<p class="font-serif text-lg font-medium text-ink group-hover:underline">
{doc.title}
@@ -74,10 +95,12 @@ const count = $derived(documents.length);
aria-hidden="true"
class="ml-4 h-5 w-5 shrink-0 opacity-30 transition-opacity group-hover:opacity-70"
/>
</a>
</div>
</li>
{/each}
</ul>
</div>
{/if}
</div>
<BulkSelectionBar canWrite={canWrite} />

View File

@@ -13,18 +13,22 @@ const rules = [
icon: '✗',
title: m.richtlinien_rule_durchgestrichen_title(),
body: m.richtlinien_rule_durchgestrichen_body(),
beispielInput: 'der Text',
beispielInputStrike: true,
beispielOutput: '[durchgestrichen: der Text]'
},
{
icon: 'ſ',
title: m.richtlinien_rule_langes_s_title(),
body: m.richtlinien_rule_langes_s_body(),
beispielOutput: 's'
beispielInput: 'ſtraße',
beispielOutput: 'straße'
},
{
icon: '?',
title: m.richtlinien_rule_name_title(),
body: m.richtlinien_rule_name_body(),
beispielInput: 'Müller',
beispielOutput: '[Müller?]'
},
{
@@ -46,7 +50,7 @@ const klaerungChips = [
<title>{m.richtlinien_title()} — Familienarchiv</title>
</svelte:head>
<div class="mx-auto max-w-2xl px-4 py-10 font-serif">
<main class="mx-auto max-w-2xl px-4 py-10 font-serif">
<!-- Title -->
<h1 class="mb-4 text-3xl font-bold text-ink">{m.richtlinien_title()}</h1>
@@ -61,10 +65,23 @@ const klaerungChips = [
target="_blank"
rel="noopener noreferrer"
referrerpolicy="no-referrer"
class="inline-flex items-center gap-1 font-sans text-sm font-medium text-ink underline decoration-brand-mint decoration-[1.5px] underline-offset-[3px]"
aria-label="{m.richtlinien_wiki_link()}{m.common_opens_new_tab()}"
class="inline-flex items-center gap-1.5 font-sans text-sm font-medium text-ink underline decoration-brand-mint decoration-[1.5px] underline-offset-[3px]"
>
{m.richtlinien_wiki_link()}
<span class="new-tab ml-1 text-[11px] text-ink-3">({m.common_opens_new_tab()})</span>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="h-3.5 w-3.5 shrink-0"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M4.25 5.5a.75.75 0 0 0-.75.75v8.5c0 .414.336.75.75.75h8.5a.75.75 0 0 0 .75-.75v-4a.75.75 0 0 1 1.5 0v4A2.25 2.25 0 0 1 12.75 17h-8.5A2.25 2.25 0 0 1 2 14.75v-8.5A2.25 2.25 0 0 1 4.25 4h5a.75.75 0 0 1 0 1.5h-5Zm6.75-3a.75.75 0 0 1 .75-.75h3.5a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0V3.56l-4.22 4.22a.75.75 0 0 1-1.06-1.06l4.22-4.22H11a.75.75 0 0 1-.75-.75Z"
clip-rule="evenodd"
/>
</svg>
</a>
</div>
@@ -78,6 +95,8 @@ const klaerungChips = [
icon={rule.icon}
title={rule.title}
body={rule.body}
beispielInput={rule.beispielInput}
beispielInputStrike={rule.beispielInputStrike}
beispielOutput={rule.beispielOutput}
beispielLabel={m.richtlinien_beispiel_label()}
/>
@@ -102,10 +121,10 @@ const klaerungChips = [
<!-- Closing card -->
<div class="border-brand-sand rounded-sm border bg-white p-6 shadow-sm">
<h2 class="mb-2 font-serif text-lg font-bold text-ink">{m.richtlinien_closing_title()}</h2>
<h3 class="mb-2 font-serif text-lg font-bold text-ink">{m.richtlinien_closing_title()}</h3>
<p class="font-serif text-sm leading-relaxed text-ink-2">{m.richtlinien_closing_body()}</p>
</div>
</div>
</main>
<style>
@media print {
@@ -113,10 +132,6 @@ const klaerungChips = [
display: none;
}
.new-tab {
display: none;
}
@page {
margin: 1.5cm;
}

View File

@@ -1 +1,3 @@
// Safe: handleAuth in hooks.server.ts redirects unauthenticated requests
// before prerendered HTML is visible.
export const prerender = true;

View File

@@ -66,6 +66,9 @@
/* Focus ring — keyboard focus indicator, mode-aware (navy in light, mint in dark) */
--color-focus-ring: var(--c-focus-ring);
/* Parchment — warm background for code/example blocks inside cards */
--color-parchment: var(--c-parchment);
/* Danger — destructive action color */
--color-danger: var(--c-danger);
--color-danger-fg: var(--c-danger-fg);
@@ -122,6 +125,9 @@
--c-danger: #c0392b;
--c-danger-fg: #ffffff;
/* Parchment — warm near-white for example blocks (light mode: cream #FAF8F1) */
--c-parchment: #faf8f1;
/* Tag color tokens — decorative dot colors on tag chips */
--c-tag-sage: #5a8a6a;
--c-tag-sienna: #a0522d;
@@ -203,6 +209,9 @@
--c-danger: #e55347;
--c-danger-fg: #ffffff;
/* Parchment — subtle surface shift for example blocks on dark navy */
--c-parchment: #041828;
/* Tag color tokens — lighter for visibility on dark backgrounds */
--c-tag-sage: #7abf8a;
--c-tag-sienna: #cc7050;
@@ -267,6 +276,9 @@
--c-danger: #e55347;
--c-danger-fg: #ffffff;
/* Parchment — subtle surface shift for example blocks on dark navy */
--c-parchment: #041828;
/* Tag color tokens — lighter for visibility on dark backgrounds */
--c-tag-sage: #7abf8a;
--c-tag-sienna: #cc7050;