331 lines
21 KiB
Markdown
331 lines
21 KiB
Markdown
# 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<Uint8Array>` 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.
|