subdocument-splitting-plan
This commit is contained in:
1 parent
f09384d2e6
commit
ca06ca2050
47 files changed
+4097
-136
No files matched your search
@@ -0,0 +1,330 @@
|
||||
# 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.
|
||||
Reference in new issue
Block a user