Files
bea-edit/plan/subdocuments-plan.md
T
2026-07-22 21:28:50 +02:00

17 KiB

Plan: Sub-documents within a ZIP archive

Goal

For a processed ZIP archive the user can already reorder attachments and export them as a single merged PDF. This feature adds the ability to:

  1. Select some of the archive's attachments and group them into a "sub-document" (a named, ordered subset of attachments).
  2. Reorder attachments within a sub-document — and reorder the sub-documents themselves — exactly like the parent archive today.
  3. Choose an export mode for the sub-documents:
    • Separate files → each sub-document becomes its own downloaded PDF.
    • One PDF → all sub-documents are merged into a single downloaded PDF (the existing top-level "PDF herunterladen" behaviour, now scoped to sub-documents when they exist).

The existing flow (no sub-documents created → one merged PDF for the whole archive) stays intact as a fallback.


Open decisions (please confirm before implementation)

These affect the data model and UI. Default proposals are marked .

D1 — Partition model

  • A. Optional grouping. Attachments can be "loose" (not part of any sub-document) or members of a sub-document. Sub-documents are created on demand by selecting attachments.
  • B. Exclusive partition. Once the user starts creating sub-documents, every attachment must belong to exactly one.
  • C. Shared/multi-membership. An attachment can appear in multiple sub-documents (references, not moves).

Recommendation: A. It is the least disruptive, matches the current "flat list" mental model, and degrades gracefully when the user does nothing.

D2 — Selection lifecycle

  • Selection mode toggle. A small "Auswählen" button in the archive header switches the editor into selection mode. Only then are checkboxes rendered on each thumbnail; in normal mode the editor looks exactly like today (no permanent checkbox clutter). While in selection mode the user toggles attachments, then clicks "Teildokument erstellen" to consume the selection (which clears the selection but leaves selection mode on, so they can immediately create another sub-document). Exiting selection mode via a "Fertig" button (or Esc) clears any pending selection.
  • A sub-document is created from the current selection. Afterwards each attachment can be moved into / out of / between sub-documents via drag-drop or a context action.
  • Alternative considered: persistent always-visible checkboxes — rejected as visually noisy for the common case where the user only wants to reorder.

D3 — Download scope when sub-documents exist

The existing single archive-level "PDF herunterladen" button behaviour needs clarification. Proposed:

  • When no sub-documents exist → today's behaviour (merge all attachments into one PDF).
  • When sub-documents do exist → show a download mode toggle offered to the user at export time:
    • Getrennt (separate): one PDF per sub-document (loose attachments are downloaded individually, matching their kind — PDF passthrough, image as-is or as a tiny PDF — TBD in D4).
    • Eine PDF (one): all sub-documents (and loose attachments, optionally — see D5) merged in sub-document order, with attachment order preserved inside each.
  • "Separate" downloads are triggered sequentially (same pattern as today's exportAllZipArchivesAsPdf).

D4 — Format of "separate" downloads

  • Each sub-document is downloaded as a PDF (merge its attachments with the existing mergeProcessedZipArchive logic, scoped to the sub-document's attachments). Naming: {archiveName} - {subDocumentName}.pdf.
  • B. Each sub-document is downloaded as a ZIP containing its original files.
  • C. Mixed: keep each attachment's original format (PDFs passthrough, images as image files).

Recommendation: A for consistency with the existing PDF-first export. We can revisit if the actual need is "preserve original files."

D5 — Treatment of loose (ungrouped) attachments at export

  • When exporting separate: loose attachments are downloaded each as their own PDF (or skipped — needs a quick confirm).
  • When exporting one PDF: loose attachments are appended at the end (in their existing order) — or skipped.
  • B. Ignore loose attachments entirely during export.

Recommendation: include loose attachments at the end of both modes, so nothing is silently lost. Confirm with user.

D6 — Naming of sub-documents

  • A sub-document has a user-editable name (default e.g. Teildokument 1, Teildokument 2, …) shown next to its attachments, mirroring the archive name input.

D7 — Persistence

  • Sub-document structure lives only in component state (same as today's reordering) — no localStorage persistence required for v1. (We already do not persist reordering.)

Data model (src/lib/zip-processing.ts)

Introduce a SubDocument type alongside ZipAttachment and extend ProcessedZipArchive.

export type SubDocument = {
  id: string;                 // stable id for keyed {#each} and drag state
  name: string;              // user-editable
  attachments: ZipAttachment[];
};

export type ProcessedZipArchive = {
  name: string;
  attachments: ZipAttachment[];   // loose attachments (unchanged shape)
  subDocuments: SubDocument[];   // NEW — defaults to [] for backward compat
};

Notes:

  • Attachments inside SubDocument reuse the existing ZipAttachment type unchanged.
  • extractZipEntries populates subDocuments: []. All existing call sites continue to work.
  • A sub-document's id is generated with crypto.randomUUID() (available in browser + modern Node) — never re-derived from index, so reordering stays stable.

New pure helpers (unit-test-friendly, no DOM)

// Create a sub-document from a list of attachments.
export const createSubDocument = (
  name: string,
  attachments: ZipAttachment[]
): SubDocument => ({ id: crypto.randomUUID(), name, attachments });

// Move an attachment from the loose list (or another sub-document) into a sub-document.
export const moveAttachmentIntoSubDocument = (
  archive: ProcessedZipArchive,
  attachmentPath: string,
  source: { kind: 'loose' } | { kind: 'subDocument'; id: string },
  target: { kind: 'loose'; index: number } | { kind: 'subDocument'; id: string; index: number }
): ProcessedZipArchive => { /* immutable update */ };

// Reorder sub-documents (same shape as the existing moveAttachment helper).
export const moveSubDocument = (
  archive: ProcessedZipArchive,
  fromIndex: number,
  toIndex: number
): ProcessedZipArchive => { /* ... */ };

// Flatten the archive into an ordered list of attachments for "one PDF" export.
//   sub-document order is preserved; loose attachments appended at the end (per D5).
export const flattenArchiveForMerge = (
  archive: ProcessedZipArchive
): ZipAttachment[] => {
  const ordered: ZipAttachment[] = [];
  for (const sub of archive.subDocuments) ordered.push(...sub.attachments);
  ordered.push(...archive.attachments);
  return ordered;
};

// Build one merged-PDF byte array per export unit.
//   - mode 'single'  → returns [ { name, bytes } ] from the whole archive (existing behaviour)
//   - mode 'separate' → returns one entry per sub-document (+ loose attachments per D5)
export type ExportUnit = { name: string; bytes: Uint8Array };
export const buildArchiveExport = async (
  archive: ProcessedZipArchive,
  mode: 'single' | 'separate'
): Promise<ExportUnit[]> => { /* ... */ };

mergeProcessedZipArchive is refactored to delegate to a new mergeAttachments(attachments): Promise<Uint8Array> (a thin rename of the existing inner loop) so both "single" and "separate" share the same merging code.


UI

ProcessedZipArchiveEditor.svelte (main work)

State additions:

  • subDocuments = $state<SubDocument[]>([]) synced from archive.subDocuments in the existing $effect.
  • selectionMode = $state<boolean>(false) — whether selection checkboxes are currently shown on thumbnails.
  • selectedPaths = $state<Set<string>>([]) — currently selected attachment paths (keyed by attachment.path, which is unique within an archive after extraction). Only meaningful while selectionMode is on.
  • downloadMode = $state<'single' | 'separate'>('single') (only shown when sub-documents exist).
  • Existing drag handlers generalised to handle three drag sources/targets:
    • loose attachment ↔ loose attachment (today's behaviour),
    • loose attachment → sub-document,
    • sub-document attachment ↔ sub-document attachment,
    • sub-document ↔ sub-document (reorder).
  • Drag-and-drop is disabled while in selection mode (so a click toggles selection rather than starting a drag).

Layout:

  • Keep the existing horizontal <ul> for loose attachments.
  • In the header, add a small toggle button labelled "Auswählen" (icon: IconMenu or a checkmark lucide icon). When active it reads "Fertig" (and selection mode is on). In normal mode thumbnails have no checkbox at all — the editor looks identical to today.
  • While in selection mode, each thumbnail card shows a checkbox in the top-left corner and the whole card becomes a click target that toggles its membership in selectedPaths. Drag-to-reorder is suspended in this mode to avoid click/drag ambiguity.
  • A new inline action bar appears above the loose-attachment list when selectionMode && selectedPaths.size > 0:
    • Button "Teildokument erstellen" → calls createSubDocument with the selected attachments, removes them from the loose list, clears selectedPaths, and keeps selectionMode on so the user can immediately build the next sub-document.
  • Esc exits selection mode (clearing selectedPaths); the "Fertig" button does the same.
  • Below the loose list, render subDocuments as a vertical stack of "sub-document blocks". Each block is itself a horizontal reorderable <ul> of attachments (same AttachmentPreview, same drag handle ::, same thumbnail width). Each block has:
    • an editable name input (mirrors archive name input styling),
    • a count label,
    • a "remove" action that returns its attachments back to the loose list,
    • a draggable header (::) to reorder the block among its siblings.
  • Sub-document blocks are draggable as a whole via their header (HTML5 DnD, mirroring the existing pattern in handleDragStart/Drop).

Export controls (header area):

  • If subDocuments.length === 0 → existing "PDF herunterladen" button + behaviour (unchanged).
  • If subDocuments.length > 0 → replace with a segmented control (Eine PDF / Getrennt) plus the download button labeled accordingly:
    • Eine PDFbuildArchiveExport(archive, 'single'), one downloadPdfBytes call.
    • GetrenntbuildArchiveExport(archive, 'separate'), sequential downloadPdfBytes per unit (matches today's exportAllZipArchivesAsPdf loop).

All state changes flow through onArchiveChange (already plumbed to updateProcessedZipFile in +page.svelte) so the top-level array stays the source of truth.

+page.svelte

  • exportAllZipArchivesAsPdf needs to honour per-archive export mode. Two options:
    • Keep it simple: bulk export uses the existing "single" mode for every archive (sub-documents flattened). Add a note in the tooltip.
    • B. Read each archive's last-chosen mode (requires propagating downloadMode up). Defer to a follow-up if desired.
  • The settings-drawer bulk export button label stays; behaviour unchanged for v1.

No changes required

  • ZipDropzone, AttachmentPreview, services, xml-reading.service, zip-inflating.service, layout.

Implementation phases

Phase 1 — Data model + pure helpers

  • Edit src/lib/zip-processing.ts:
    • Add SubDocument type and extend ProcessedZipArchive with subDocuments: SubDocument[] (default []).
    • Set subDocuments: [] in extractZipEntries.
    • Extract mergeAttachments(attachments: ZipAttachment[]): Promise<Uint8Array> from mergeProcessedZipArchive.
    • Add the pure helpers: createSubDocument, moveAttachmentIntoSubDocument, moveSubDocument, removeAttachmentFromSubDocument (returns attachments to the loose list), flattenArchiveForMerge, buildArchiveExport.
  • Verification: TypeScript compiles (pnpm check / svelte-check); existing tests (if any) still pass; existing UI still renders unchanged.

Phase 2 — Sub-document block component

  • New src/lib/components/SubDocumentEditor.svelte:
    • Props: subDocument, thumbnailWidth, onSubDocumentChange, onRemove, drag handlers (or pass a generic reorder callback).
    • Renders the editable name, count, remove button, draggable header, horizontal attachment list (reusing AttachmentPreview + the same DnD styling).
    • Internally mirrors ProcessedZipArchiveEditor's drag logic, scoped to the block.
  • Verify: can be unit-rendered in isolation (storybook-equivalent or a throwaway page snippet).

Phase 3 — Selection mode + sub-document creation in ProcessedZipArchiveEditor

  • Add selectionMode and selectedPaths state and the "Auswählen" / "Fertig" header toggle.
  • While selectionMode is on, render a checkbox on each loose thumbnail and make the whole card a click-to-toggle target; suspend drag-to-reorder to avoid click/drag ambiguity.
  • Add the inline action bar with "Teildokument erstellen", visible when a selection exists.
  • On create: call createSubDocument, remove the selected attachments from the loose list, clear selectedPaths, keep selectionMode on, and emit onArchiveChange.
  • Esc / "Fertig" exits selection mode and clears the selection.
  • On sub-document removal: push its attachments back into the loose list (appended at the end by default).
  • Verify: toggling selection mode shows/hides checkboxes; in normal mode the editor looks identical to today; creating, populating, and removing sub-documents works end-to-end with reordering still working for loose attachments.

Phase 4 — Sub-document drag interactions

  • Generalise the existing HTML5 DnD handlers to support the three move scenarios listed above. Use the dataTransfer payload to encode { source: 'loose' | sub:${id}, index }.
  • Reorder sub-document blocks via header drag.
  • Verify: items can be moved loose↔sub and sub↔sub; sub-document order can be changed; nothing is duplicated or lost (count invariant: loose.length + Σ sub.attachments.length === total attachments).

Phase 5 — Export modes

  • Replace header download button with the segmented control + button when sub-documents exist.
  • Wire buildArchiveExport(archive, mode) to downloadPdfBytes (single) and the sequential loop (separate).
  • +page.svelte: keep exportAllZipArchivesAsPdf in single mode; optionally surface a small hint when an archive has sub-documents.
  • Verify:
    • No sub-documents → existing single-PDF download unchanged.
    • With sub-documents, "Eine PDF" flattens and downloads one PDF.
    • With sub-documents, "Getrennt" downloads N files named {archiveName} - {subName}.pdf.

Phase 6 — Polish

  • Empty states (e.g. empty sub-document block, no loose attachments).
  • Disable the create-sub-document button when selection is empty.
  • Accessible labels + keyboard operability for checkboxes / segmented control (bits-ui SegmentedControl if available, otherwise native radio group).
  • Update agents.md "ZIP/PDF processing details" + "UI/state details" sections to mention sub-documents.
  • Manual smoke test on a real xjustiz ZIP with nested archives.

Verification checklist (definition of done)

  • pnpm check (or svelte-check) passes with no new errors.
  • pnpm build succeeds.
  • Loading a ZIP without creating sub-documents produces the exact same merged PDF as today.
  • Toggling "Auswählen" shows checkboxes on thumbnails; toggling "Fertig" (or pressing Esc) hides them and clears the selection. In normal mode the editor looks identical to today (no checkboxes).
  • Selecting ≥1 attachments and clicking "Teildokument erstellen" removes them from the loose list and adds a named sub-document block, then clears the selection while staying in selection mode for the next group.
  • Drag-reordering works for: loose attachments, attachments inside a sub-document, sub-document blocks themselves, and moving an attachment loose↔sub.
  • With sub-documents present:
    • "Eine PDF" downloads a single PDF containing every attachment (sub-documents first, then loose).
    • "Getrennt" downloads one PDF per sub-document, each correctly named.
  • Removing a sub-document returns its attachments to the loose list; total attachment count is invariant.
  • Bulk "Alle ZIP-Archive als PDF exportieren" still works (single mode).
  • agents.md updated.

Out of scope (follow-ups)

  • Persisting sub-document structure to localStorage.
  • Renaming attachments themselves (only sub-document names are editable in v1).
  • Exporting "separate" as ZIPs or original-format files (see D4-C).
  • Per-archive export mode remembered by the bulk exporter (D3 follow-up).
  • Drag-and-drop between different archives in +page.svelte (today each editor is self-contained).