# Plan: Teildokumente besser aufteilen (PDF & beA-ZIP) ## Goal Splitting an archive's content into multiple Teildokumente is a core workflow of the beA editor, but the current UX handles only the easy half of it. This plan closes two gaps: 1. **PDFs split into sub-documents.** A single PDF attachment (e.g. one scan containing several documents) cannot be divided at page level today. The user has to leave the app, split the PDF with an external tool, and re-zip the results — the beA route only accepts ZIPs. 2. **Grouping UX.** Creating and re-arranging Teildokumente relies on a modal-ish selection mode and HTML5 drag-and-drop only. It is hard to discover, hard to use on touch devices (relevant since the app is an installable PWA), and offers no path for regrouping attachments that are already inside a Teildokument except dissolving the whole block. All features stay 100 % client-side, German UI copy, no new dependencies. --- ## Current-state analysis (UX pain points) | # | Pain point | Where | | --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | | P1 | A multi-document PDF attachment cannot be split into several Teildokumente at all. | `ProcessedZipArchiveEditor.svelte` — attachments are atomic | | P2 | PDFs cannot enter the beA workspace; only ZIPs are accepted. Splitting a PDF means zipping it first. | `zip-selection.ts`, `ZipDropzone.svelte` | | P3 | Grouping is hidden behind an unlabeled-purpose "Auswählen" mode toggle; the action bar with "Teildokument erstellen" only appears after the first checkbox, so the goal of the mode is unclear. | `ProcessedZipArchiveEditor.svelte` | | P4 | Selection mode only covers loose attachments. To regroup attachments that already sit in a Teildokument, the user must dissolve the whole block (all-or-nothing) or drag each file individually. | `ProcessedZipArchiveEditor.svelte`, `SubDocumentEditor.svelte` | | P5 | Moving attachments relies exclusively on HTML5 drag-and-drop, which does not work with touch input and has no keyboard alternative. | both editors | | P6 | New Teildokumente get the name `Teildokument ${subDocuments.length + 1}` — dissolving and re-creating produces duplicate names — and the name input is not focused, so the rename step is easy to miss. | `createSubDocumentFromSelection` | | P7 | The newly created Teildokument renders far below the loose list; there is no scroll-into-view or other confirmation. | `ProcessedZipArchiveEditor.svelte` | | P8 | No quick actions for the most common grouping patterns: "every attachment becomes its own Teildokument", "create empty Teildokument and fill it". | — | | P9 | `tests/e2e/bea.spec.ts` has zero coverage of the sub-document flow (no grouping, no drag, no export-mode tests). | `tests/e2e/bea.spec.ts` | --- ## Open decisions (defaults marked ✅) ### D1 — Entry point for PDFs - ✅ **A. Accept PDFs in the beA workspace.** Extend the beA dropzone/pickers to also accept PDF files. A PDF is turned into a `ProcessedZipArchive` with `name: {fileName}.pdf`, `attachments: [ {kind: 'pdf', data, path: fileName, name: fileName} ]`, `subDocuments: []`. The archive editor then offers the full splitting workflow on it. - B. Keep beA ZIP-only; route PDF splitting to the planned standalone `/tools/extract` (extract-split-plan.md). Rejected: its output is a ZIP of PDFs, which the user would then have to re-import — a disconnected round trip. > Naming note: the route heading and dropzone copy should mention PDFs > ("beA-ZIP oder PDF auswählen") so the entry point is discoverable. ### D2 — Page-level split interaction - ✅ **A. Split dialog per PDF attachment.** A "Teilen" action on a PDF attachment opens a dialog that reuses `loadPdfDocument` + `PdfPageGrid` (same pattern as `tools/separate`). The user selects a page set, clicks "Als Teildokument übernehmen", the dialog stays open with the remaining pages and a fresh empty selection for the next Teildokument, until "Fertig". This guided loop mirrors how documents are actually separated (page range by page range). - B. Inline page expansion in the archive editor (expand thumbnails for every attachment). Rejected: doubles memory (pdfjs documents for every PDF stay alive) and clutters the editor. ### D3 — What happens to the source PDF - ✅ **A. "Ausschneiden" (cut) by default.** The selected pages move into the new Teildokument; the source attachment keeps the remaining pages. If all pages are consumed, the source attachment is removed. A checkbox "Seiten im Original behalten" (copy mode) is available for the rare case where pages should appear in two Teildokumente. - B. Copy-only. Rejected: the feature's purpose is _splitting_; copy-only leaves the user with manual cleanup every time. ### D4 — Grouping mode redesign - ✅ **A. Keep a temporary grouping mode, but make it self-explanatory and archive-wide.** Rename the toggle to **"Gruppieren"** (with a layers/group icon). While active: - the action bar is always visible (disabled at 0 selected), showing the count and the "Teildokument erstellen" button, - checkboxes appear on loose **and** sub-document attachments, so regrouping no longer requires dissolving, - the bar offers **"In Teildokument verschieben"** (a select of existing Teildokumente) next to "Teildokument erstellen", covering both create-new and move-into-existing in one place, - "Alle auswählen" / "Auswahl aufheben" buttons are added (parity with the separate tool). - B. Remove the mode entirely in favour of per-attachment menus only. Rejected: multi-select is the fastest way to group many attachments; menus are one-file-at-a-time. ### D5 — Touch/keyboard-friendly moves - ✅ **A. Per-attachment action menu.** Each attachment card gets a small "⋯" menu (visible on hover/focus, always rendered for screen readers and touch) with: - "In Teildokument verschieben →" (existing Teildokumente + "Neues Teildokument"), - "Teilen…" (PDFs only, opens the D2 dialog), - "Entfernen" (remove attachment from the archive, with confirm). Drag-and-drop stays as the fast path for pointer users. - B. Up/down move buttons on every card. Rejected: six buttons per card is visual noise, and it cannot express "into another Teildokument" without a target chooser anyway. ### D6 — Bulk split modes in the dialog - ✅ **A. Offer "Jede Seite einzeln" and "Alle N Seiten"** alongside the manual page selection (same modes as extract-split-plan.md D2). Each chunk becomes one Teildokument named `Seiten {first}–{last}`; the source attachment is removed (D3 cut semantics). - B. Manual ranges only for v1. Kept as the fallback if the chunking logic proves fiddly. ### D7 — Naming of new Teildokumente - ✅ **A.** Keep the editable inline name, but number with `Math.max` of existing numbers + 1 (parse trailing digits of names starting with "Teildokument") so dissolve/re-create cycles no longer produce duplicates, and **focus + select the name input** of the freshly created block, then scroll it into view. The split dialog (D2) pre-fills the name from the page range ("Seiten 1–3") and lets the user edit it inline per iteration. --- ## UX concept ### Workstream A — Grouping UX (beA-ZIP → Teildokumente) 1. **"Gruppieren" toggle** in the archive header (replaces "Auswählen"/"Fertig"). Archive-wide multi-select with an always-visible action bar: `N Dateien ausgewählt` · "Teildokument erstellen" · "In Teildokument verschieben ▾" · "Alle auswählen" / "Auswahl aufheben". Escape/"Fertig" exits and clears. 2. **Per-attachment "⋯" menu** (D5) for single-file moves and splitting without entering any mode. 3. **Empty Teildokument**: new header button "Neues Teildokument" creates an empty block (the existing empty-drop-zone state in `SubDocumentEditor` is already the right affordance). 4. **Quick action**: "Jede Datei als Teildokument" in the header menu converts all loose attachments into one Teildokument each, in current order. 5. **Feedback**: newly created blocks are scrolled into view; their name input is focused. ### Workstream B — Page-level splitting (PDF → Teildokumente) 1. **PDF acceptance** in the beA workspace (D1): dropzone, file picker, and the start-page drop-forward accept `.pdf` alongside ZIPs; a dropped PDF opens the same workspace. 2. **"Teilen…" dialog** (D6/D3): page grid via `PdfPageGrid`, selection via click, live label ("3 von 12 Seiten ausgewählt"), "Als Teildokument übernehmen" applies the cut and re-opens for the next range; bulk radio "Jede Seite einzeln" / "Alle N Seiten" with a file-count preview ("Es werden 4 Teildokumente erstellt."). "Fertig" closes. 3. The generated chunks are regular PDF attachments (kind `'pdf'`), so export, reordering, and previews need no special cases. --- ## Technical approach ### Data model (`src/lib/zip-processing.ts`) `SubDocument` and `ProcessedZipArchive` stay unchanged. Splitting produces **synthetic attachments** (implemented helpers; page _sets_ instead of plain ranges so arbitrary grid selections work — a range is the special case used by the bulk modes): ```ts export type PdfSplitResult = { groups: Array<{ name: string; attachment: ZipAttachment }>; source: ZipAttachment | null; // remaining pages; null removes the source }; // New helpers (pure, immutable — same style as moveAttachmentIntoSubDocument): createAttachmentFromPages(source, pages: number[], name?) // copy a 1-based page set into a new attachment removePagesFromAttachment(source, pages: number[]) // complement; null when empty applyPdfSplit(archive, sourcePath, result) // replace source, insert new sub-documents after its parent block pdfFileToArchive(file) // D1: PDF → single-attachment archive nextSubDocumentNumber(subDocuments) // max trailing "Teildokument N" number + 1 createSubDocumentFromPaths(archive, paths) // group any selection into a new Teildokument moveAttachmentsByPathsToSubDocument(archive, paths, id) // move selection into an existing Teildokument groupEachAttachmentAsSubDocument(archive) // quick action: one Teildokument per loose file removeAttachmentFromArchive(archive, path) // menu "Entfernen" ``` - **Unique `path`s are mandatory**: `path` is the `{#each}` key and the drag/selection identity (`selectedPaths`). The `#seiten-…-uuid` suffix guarantees uniqueness even when the same range is split twice (copy mode). - Splitting an attachment **inside a Teildokument** inserts the new Teildokument **directly after the source's parent block** and applies the cut to the copy inside that block, so the user's reading order is preserved. ### Shared page extraction Implement `copyPagesToNewPdf(sourceBytes, pageNumberList): Promise` in `src/lib/pdf-processing.ts` exactly as specified in extract-split-plan.md D3, and rewire `tools/separate` to use it. If `/tools/extract` is built first, reuse the helper — the two plans must not fork the code. ### Components - **New `src/lib/components/PdfSplitDialog.svelte`**: props `{ attachment, onConfirm }`. Loads thumbnails through `loadPdfDocument` and destroys the handle on close (same memory safeguards as `AttachmentPreview`); rendering happens only while open. Uses the existing shadcn dialog primitives. - **New `src/lib/components/AttachmentMenu.svelte`**: the "⋯" menu (D5), built on the existing shadcn dropdown-menu primitives; used by both editors. - **`ProcessedZipArchiveEditor.svelte`**: grouping-mode rework (D4), quick actions, "Neues Teildokument" button, split-dialog wiring, scroll-into-view + name focus after creation. - **`SubDocumentEditor.svelte`**: render the "⋯" menu per attachment; grouping-mode checkboxes forward selection to the parent (selection stays owned by the archive editor). ### PDF acceptance (D1) - `zip-selection.ts`: add `PDF_ACCEPT_TYPES`/`isAcceptedPdfFile` and a combined `isAcceptedWorkspaceFile`. - `ZipDropzone.svelte`, both compact pickers, and the start-page drop-forward: accept PDFs; the beA page routes PDF files to a `pdfFileToArchive(file)` wrapper that produces the synthetic single-attachment archive. - Jobs list: a PDF job shows the same processing row; it cannot fail like a nested-ZIP extraction can (only a pdf-lib load error, surfaced with the existing German error copy). ### Invariants (from agents.md, restated as acceptance criteria) - No move/split operation may duplicate or discard attachment data (cut moves bytes, copy duplicates them deliberately and only on explicit user request). - Teildokument IDs stay stable across edits and reordering. - Single-PDF export order stays: Teildokumente in order, each attachment in order, loose attachments last. - Dissolving a Teildokument appends its attachments to the loose list (unchanged). - All state helpers return new archive objects; the route remains the source of truth. --- ## Files changed | File | Change | | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------- | | `src/lib/pdf-processing.ts` | `copyPagesToNewPdf` (shared with extract-split-plan) | | `src/lib/zip-processing.ts` | page-range helpers, `createSubDocumentPerAttachment`, `nextSubDocumentNumber`, `pdfFileToArchive` | | `src/lib/components/PdfSplitDialog.svelte` | new — page-selection dialog with manual + bulk modes | | `src/lib/components/AttachmentMenu.svelte` | new — per-attachment actions | | `src/lib/components/ProcessedZipArchiveEditor.svelte` | grouping-mode rework, quick actions, split wiring | | `src/lib/components/SubDocumentEditor.svelte` | attachment menu, grouping-mode checkboxes | | `src/lib/zip-selection.ts`, `ZipDropzone.svelte`, `ZipFilePicker.svelte` | PDF acceptance | | `src/routes/tools/bea/+page.svelte`, `src/routes/+page.svelte` | PDF → archive wrapper, drop-forward | | `tests/e2e/bea.spec.ts` (+ fixtures) | sub-document, grouping, splitting coverage | | `agents.md` | flow + invariants updates | --- ## Implementation phases ### Phase 1 — Shared extraction helper + pure helpers `copyPagesToNewPdf`, page-range/splitting helpers, `nextSubDocumentNumber`, `pdfFileToArchive`. Unit-verifiable pure functions, no UI. Rewire `tools/separate` to the shared helper. ### Phase 2 — Grouping UX rework "Gruppieren" mode with always-visible action bar, archive-wide selection, move-into-existing select, select all/none, naming/numbering fix, focus + scroll-into-view feedback. ### Phase 3 — Attachment menu + quick actions `AttachmentMenu.svelte` in both editors (touch/keyboard path), "Neues Teildokument", "Jede Datei als Teildokument". ### Phase 4 — Split dialog `PdfSplitDialog.svelte` with manual cut/copy loop and bulk modes; wire "Teilen…" from the attachment menu; insertion after the source block. ### Phase 5 — PDF acceptance in the beA workspace Selection types, dropzone/picker/start-page plumbing, synthetic archive, job error copy. ### Phase 6 — Polish, agents.md, tests Copy pass, empty/edge states, `agents.md` update, full e2e suite. Phases 2–5 are independent enough to land separately (each behind the Phase 1 helpers where it needs them). --- ## Verification - `pnpm check`, `pnpm lint`, `pnpm build` — no new errors. - `pnpm test:e2e` with new cases in `bea.spec.ts` (current suite has no sub-document coverage): - group loose attachments via "Gruppieren" → names/counts correct, order preserved; - select attachments inside existing Teildokumente and move them into another Teildokument without dissolving; - regroup without drag-and-drop (menu path — this is also the touch-equivalent path); - split a PDF attachment: cut three pages into a new Teildokument, remainder stays as source, both export correctly via "Eine PDF" and "Getrennt"; - bulk mode "Jede Seite einzeln" produces one Teildokument per page, source removed; - drop a bare PDF onto the beA dropzone and onto the start page → workspace with one attachment; split and export; - duplicate-name regression: dissolve + re-create → "Teildokument 2", not a second "Teildokument 1". - Manual: run the split dialog on a 100+ page scan (thumbnail memory, dialog close cleanup); touch device pass for menu-based moves. - `pnpm test:pwa` still passes (PDF acceptance changes the dropzone precached flow inputs only). --- ## Risks - **Memory**: the split dialog keeps a pdfjs document + thumbnails alive while open. Mitigated by the dialog lifecycle (load on open, `destroy()` on close/discard) and the existing managed thumbnail service. - **Path collisions**: synthetic paths must be globally unique per archive; the uuid suffix makes this safe, but the grouping/selection code must never assume `path` is a real file-system path (it already only uses it as identity). - **Forking with extract-split-plan**: both plans need `copyPagesToNewPdf`. Whichever lands first owns the helper; the other must reuse it. - **Copy-mode confusion**: cut vs copy must be visually explicit in the dialog (default checked = cut), otherwise users may assume data loss. ## Out of scope - Undo/redo for archive edits (separate plan; this plan's helpers are immutable, which is the prerequisite for it). - ZIP export of the edited structure (export-zip-plan.md). - Free-form range input ("1-3, 5, 8-10") in the split dialog — follow-up after v1. - Splitting image attachments into pages (images are single-page by definition). - XJustiz-aware auto-grouping (e.g. pre-filling Teildokument names from document names) — candidate for a later plan once the metadata panel (xjustiz-metadata-panel-plan.md) exists. --- ## Status: Implemented Deviations from the original text, made during implementation: - Page helpers work on **1-based page sets** (`createAttachmentFromPages`, `removePagesFromAttachment`) instead of `PageRange` only, because the manual dialog mode selects arbitrary grid pages; the bulk modes pass contiguous sets. - The split dialog applies the cut **once on "Teilen"** (accumulating groups with "Als Teildokument übernehmen" first) rather than re-encoding the source after every assignment; the semantics are identical and the grid shows assigned pages with a "✓ Zugewiesen" badge. - Split-chunk attachments use the source-based default names (`vertrag_Seiten_1-2.pdf`); the user-facing names live on the Teildokumente. - Toolbar labels became file-neutral ("Neue Datei bearbeiten", "Weitere Datei hinzufügen") because the same pickers now accept PDFs (D1); `agents.md` and the e2e helpers were updated accordingly. - `PdfPageGrid.svelte` renders the `pageActions` snippet in its clickable branch too (previously only the static branch), needed for the assigned-page badges. - The workspace status counts files ("1 Datei · 1 Archiv geöffnet") instead of ZIPs.