From ca06ca20509075206824afc9b0de61f7ad6b0623 Mon Sep 17 00:00:00 2001 From: rehlert Date: Tue, 6 Oct 2026 22:52:35 +0200 Subject: [PATCH] subdocument-splitting-plan --- agents.md | 54 ++- plan/done/batch-processing-plan.md | 143 ++++++ plan/done/bea-settings-menu-redesign-plan.md | 315 +++++++++++++ plan/done/metadata-plan.md | 139 ++++++ plan/done/rotate-plan.md | 140 ++++++ plan/done/stamp-plan.md | 139 ++++++ plan/done/start-page-bea-wording-plan.md | 126 +++++ plan/done/start-page-toolbox-plan.md | 259 +++++++++++ plan/done/subdocument-download-plan.md | 188 ++++++++ plan/done/subdocument-splitting-ux-plan.md | 330 +++++++++++++ plan/done/subdocuments-plan.md | 283 +++++++++++ src/lib/components/AttachmentMenu.svelte | 100 ++++ .../components/BeaWorkspaceControls.svelte | 14 +- src/lib/components/PdfPageGrid.svelte | 3 + src/lib/components/PdfSplitDialog.svelte | 438 ++++++++++++++++++ .../ProcessedZipArchiveEditor.svelte | 318 +++++++++++-- src/lib/components/SubDocumentEditor.svelte | 107 ++++- src/lib/components/ZipDropzone.svelte | 8 +- src/lib/components/ZipFilePicker.svelte | 6 +- .../dropdown-menu-checkbox-group.svelte | 16 + .../dropdown-menu-checkbox-item.svelte | 46 ++ .../dropdown-menu-content.svelte | 31 ++ .../dropdown-menu-group-heading.svelte | 22 + .../dropdown-menu/dropdown-menu-group.svelte | 7 + .../dropdown-menu/dropdown-menu-item.svelte | 27 ++ .../dropdown-menu/dropdown-menu-label.svelte | 24 + .../dropdown-menu/dropdown-menu-portal.svelte | 7 + .../dropdown-menu-radio-group.svelte | 16 + .../dropdown-menu-radio-item.svelte | 36 ++ .../dropdown-menu-separator.svelte | 17 + .../dropdown-menu-shortcut.svelte | 23 + .../dropdown-menu-sub-content.svelte | 31 ++ .../dropdown-menu-sub-trigger.svelte | 29 ++ .../ui/dropdown-menu/dropdown-menu-sub.svelte | 7 + .../dropdown-menu-trigger.svelte | 7 + .../ui/dropdown-menu/dropdown-menu.svelte | 7 + src/lib/components/ui/dropdown-menu/index.ts | 54 +++ src/lib/pdf-processing.ts | 24 + src/lib/zip-processing.ts | 355 ++++++++++++++ src/lib/zip-selection.ts | 10 + src/routes/+page.svelte | 25 +- src/routes/tools/bea/+page.svelte | 10 +- src/routes/tools/separate/+page.svelte | 11 +- tests/e2e/bea.spec.ts | 249 +++++++++- tests/e2e/helpers.ts | 16 +- tests/e2e/pwa.spec.ts | 2 +- tests/e2e/start-page.spec.ts | 14 + 47 files changed, 4097 insertions(+), 136 deletions(-) create mode 100644 plan/done/batch-processing-plan.md create mode 100644 plan/done/bea-settings-menu-redesign-plan.md create mode 100644 plan/done/metadata-plan.md create mode 100644 plan/done/rotate-plan.md create mode 100644 plan/done/stamp-plan.md create mode 100644 plan/done/start-page-bea-wording-plan.md create mode 100644 plan/done/start-page-toolbox-plan.md create mode 100644 plan/done/subdocument-download-plan.md create mode 100644 plan/done/subdocument-splitting-ux-plan.md create mode 100644 plan/done/subdocuments-plan.md create mode 100644 src/lib/components/AttachmentMenu.svelte create mode 100644 src/lib/components/PdfSplitDialog.svelte create mode 100644 src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-group.svelte create mode 100644 src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte create mode 100644 src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte create mode 100644 src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte create mode 100644 src/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte create mode 100644 src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte create mode 100644 src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte create mode 100644 src/lib/components/ui/dropdown-menu/dropdown-menu-portal.svelte create mode 100644 src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte create mode 100644 src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte create mode 100644 src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte create mode 100644 src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte create mode 100644 src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte create mode 100644 src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte create mode 100644 src/lib/components/ui/dropdown-menu/dropdown-menu-sub.svelte create mode 100644 src/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte create mode 100644 src/lib/components/ui/dropdown-menu/dropdown-menu.svelte create mode 100644 src/lib/components/ui/dropdown-menu/index.ts diff --git a/agents.md b/agents.md index fa324e6..64ed33b 100644 --- a/agents.md +++ b/agents.md @@ -3,8 +3,9 @@ ## Project overview This repository contains **juri-merger**, a German-language SvelteKit application branded as -**beA-Edit**. Users add one or more ZIP archives in the browser, inspect and reorder supported -attachments, optionally group attachments into named sub-documents, and export merged PDFs. +**beA-Edit**. Users add one or more beA ZIP archives or PDF files in the browser, inspect and +reorder supported attachments, group attachments into named sub-documents (including page-level +PDF splitting), and export merged PDFs. Nested ZIP archives and XJustiz metadata are handled client-side; there is no upload, server-side archive processing, database, analytics, or persistence of case data. @@ -69,10 +70,12 @@ src/ │ ├── assets/ # Favicon and local Nunito fonts │ ├── components/ │ │ ├── AttachmentPreview.svelte # Browser-rendered PDF/image thumbnails +│ │ ├── AttachmentMenu.svelte # Per-attachment dropdown menu (move, split, remove) │ │ ├── BatchFileList.svelte # Shared batch rows: status chips, downloads, retry │ │ ├── BeaArchiveProcessing.svelte │ │ ├── BeaWorkspaceControls.svelte # beA workspace toolbar, settings popover, dialogs │ │ ├── ProcessedZipArchiveEditor.svelte +│ │ ├── PdfSplitDialog.svelte # Page-level PDF splitting into Teildokumente │ │ ├── SubDocumentEditor.svelte │ │ ├── FileDropzone.svelte # Shared PDF/image/ZIP file picker and drop target │ │ ├── ZipFilePicker.svelte # Compact ZIP picker button with hidden resettable input @@ -142,8 +145,8 @@ committed. ## Application flow and state -- `/` is the toolbox hub. A ZIP dropped anywhere on that route is queued in memory and forwarded to - `/tools/bea`; the file is not uploaded or persisted. +- `/` is the toolbox hub. A beA ZIP or PDF dropped anywhere on that route is queued in memory and + forwarded to `/tools/bea`; the file is not uploaded or persisted. - `/tools/metadata` is a single-PDF inspection tool. `src/lib/pdf-metadata.ts` reads document properties with pdf-lib only (no rasterization), returns a discriminated `{ status: 'encrypted' }` result for password-protected files, and detects embedded-file markers by scanning raw bytes. @@ -153,41 +156,61 @@ committed. thumbnail-width preference. Thumbnail width is the only persisted UI setting and uses localStorage. - The loaded workspace renders `BeaWorkspaceControls.svelte`, a top-aligned toolbar with the workspace status and two archive lifecycle actions: - - **“Neue ZIP bearbeiten”** (replace) opens a ZIP picker and, after the user confirms the + - **“Neue Datei bearbeiten”** (replace) opens a file picker and, after the user confirms the replacement dialog, calls `replaceWithZipFiles(files)` which resets the workspace and enqueues the new files against the incremented generation. Files picked for replacement stay in temporary component state until confirmation; cancelling the picker or the dialog must never mutate the current workspace. - - **“Weitere ZIP hinzufügen”** (append) lives in the settings popover, uses the plain append path + - **“Weitere Datei hinzufügen”** (append) lives in the settings popover, uses the plain append path (`handleFilesSelected`), and never clears current archives. Keep the two labels distinct: - replace is “Neue ZIP bearbeiten”, append is “Weitere ZIP hinzufügen”. + replace is “Neue Datei bearbeiten”, append is “Weitere Datei hinzufügen”. - `BeaWorkspaceControls.svelte` owns only ephemeral UI state (popover/dialog open state and pending replacement files). The route remains the source of truth for files, jobs, archives, and the persisted thumbnail width. - `resetWorkspace()` is the single teardown helper: it increments `archiveGeneration` and clears selected files, processed archives, and jobs. Never clear those arrays without going through it. -- Accepted ZIP types and the file filter for the dropzone and both compact pickers are centralized in - `src/lib/zip-selection.ts`; `ZipFilePicker.svelte` resets its input after every selection so the +- Accepted file types for the workspace (beA ZIP **and** PDF) are centralized in + `src/lib/zip-selection.ts` and shared by the dropzone, the start-page drop target, and both + compact pickers; `ZipFilePicker.svelte` resets its input after every selection so the same file can be chosen again. -- ZIP files are processed concurrently. `archiveGeneration` prevents results from an old batch from - reappearing after the user deletes all archives **or** confirms a replacement. +- Workspace files are processed concurrently. PDF files become single-attachment archives via + `pdfFileToArchive`; ZIP files go through `extractZipArchives`. `archiveGeneration` prevents + results from an old batch from reappearing after the user deletes all archives **or** confirms a + replacement. - `ProcessedZipArchiveEditor.svelte` edits one archive and emits immutable replacements through `onArchiveChange`; the route-level archive array remains the source of truth. - Attachments may remain loose or belong to exactly one sub-document. Drag-and-drop supports reordering and moving attachments between lists, while sub-document headers can be reordered. -- Selection mode creates sub-documents from loose attachments. Exiting with **Fertig** or Escape - clears the pending selection. +- **Gruppieren** (grouping mode) offers archive-wide multi-select over loose and sub-document + attachments: create a new Teildokument from the selection, move the selection into an existing + Teildokument, select all/none. Exiting with **Fertig** or Escape clears the pending selection. + New Teildokumente are numbered via `nextSubDocumentNumber` (max existing trailing number, not + list length), and the created block's name input is focused and scrolled into view. +- Each attachment card has an **“⋯” menu** (`AttachmentMenu.svelte`) as the keyboard/touch path: + move into an existing or new Teildokument, move back to the loose list, split PDFs, and remove + (with confirmation). It must never become the only way to perform an action that drag-and-drop + also covers. +- **PDF splitting**: `PdfSplitDialog.svelte` opens per PDF attachment, renders pages through + `loadPdfDocument` + `PdfPageGrid`, and assigns page sets to named groups (manual selection, + “Jede Seite einzeln”, or “Alle N Seiten”). Cut is the default; “Seiten im Original behalten” + switches to copy. `applyPdfSplit` replaces the source with the remaining pages (or removes it), + inserts the new Teildokumente after the source's parent block, and the synthetic attachments + get unique `#seiten-…` path suffixes because `path` is the identity for keyed lists, drag + payloads, and selection. - Per-archive export supports one flattened PDF or separate PDFs for sub-documents. Each sub-document also has its own direct download action that exports only that sub-document's attachments. Bulk export from the page always creates one flattened PDF per archive. When changing archive transforms, preserve these invariants: -- An attachment move must not duplicate or discard data. +- An attachment move must not duplicate or discard data. Splitting cuts pages by default and only + duplicates them when the user explicitly chooses copy mode. - Sub-document IDs remain stable across edits and reordering. - Single-PDF order is all sub-documents in order, each attachment in order, followed by loose attachments in order. - Dissolving a sub-document appends its attachments to the loose list. +- Splitting inserts the new Teildokumente directly after the source's parent block (loose source: + appended at the end) so the reading order is preserved. - State helpers in `zip-processing.ts` should return new archive/list objects rather than mutating component-owned state. @@ -205,6 +228,9 @@ All case-file processing is intentionally local to the browser. extension classification and downstream preview/export behavior together when adding formats. - PDF attachments are copied with pdf-lib. JPEG and PNG files can be embedded directly; other image formats are decoded through browser image/canvas APIs and converted to PNG first. +- Page-set copies (separate/extract tools, PDF splitting) share + `copyPagesToNewPdf(sourceBytes, pageIndexes)` in `src/lib/pdf-processing.ts` so the copy + semantics cannot drift apart. - `AttachmentPreview.svelte` dynamically loads pdfjs-dist in `onMount`, copies PDF bytes before rendering, and revokes object URLs during cleanup. Preserve those memory and data-integrity safeguards. diff --git a/plan/done/batch-processing-plan.md b/plan/done/batch-processing-plan.md new file mode 100644 index 0000000..72b451d --- /dev/null +++ b/plan/done/batch-processing-plan.md @@ -0,0 +1,143 @@ +# Plan: Batch-Verarbeitung in den Einzel-PDF-Tools + +## Goal + +Several tools process exactly one PDF at a time, but real legal work is repetitive: compress ten +scans, watermark five drafts, encrypt three exhibits. This plan generalizes the single-file +tools to accept **multiple files**, process them sequentially with per-file status, and download +results individually or as one ZIP. + +--- + +## Open decisions (defaults marked ✅) + +### D1 — Rollout scope + +- ✅ **A. Phased rollout, starting with compress.** Compress is the highest-repetition tool and + already has the cleanest input→output shape. Phase 2: watermark (needs option application per + file), stamp/page-numbers (same pattern). Phase 3: encrypt/decrypt (decrypt needs a password + for all files — one shared password input). +- B. All tools at once. Too much surface; the shared pattern should prove itself first. + +### D2 — UX model + +- ✅ **A. Always multi-file.** The dropzone accepts multiple PDFs from day one; a single dropped + file simply renders a one-row list. No mode switch. Download: one button per finished row + plus "Alle als ZIP", which appears once ≥2 files are done. +- B. Explicit "Batch-Modus" toggle. Extra clicks and duplicated code paths. + +### D3 — Failure behavior + +- ✅ **Continue on error.** A failing file is marked with a German error message in its row; the + queue keeps processing the rest. "Erneut versuchen" per failed row. This matches how lawyers + will use it (one broken scan must not kill the run). + +--- + +## UX + +Per retrofitted tool page: + +1. `PdfDropzone` with `multiple` enabled; dropping more files appends to the current list + (does not replace it) — matches how the start page queues ZIPs. +2. **File list** below the options card: one row per file — + name, size, status chip (Wartet / Verarbeite… / Fertig ✓ mit neuer Größe / Fehler), + per-row download button (enabled when done), per-row remove, "Erneut versuchen" on error. +3. Progress line during a run: "Datei 3 von 7 wird verarbeitet…". +4. Footer actions: **"Alle herunterladen"** (ZIP via fflate, named + `{toolname}_{date}.zip`, e.g. `komprimiert_2026-02-12.zip`) visible when ≥2 results exist. +5. Options (preset, watermark settings, password…) apply to **all** files in the run; changing + options while a run is active affects only subsequent files (documented in helper text). + +## Technical approach + +### Shared batch engine (`src/lib/components/BatchFileList.svelte` + `src/lib/batch.ts`) + +```ts +// src/lib/batch.ts +export type BatchItemStatus = 'pending' | 'processing' | 'done' | 'error'; +export type BatchItem = { + id: string; // crypto.randomUUID() + file: File; + status: BatchItemStatus; + resultBytes?: Uint8Array; + resultName?: string; + errorMessage?: string | null; +}; + +export const runBatch = async ( + items: BatchItem[], + process: (file: File) => Promise<{ bytes: Uint8Array; name: string }>, + update: (id: string, patch: Partial) => void +): Promise; +``` + +- `runBatch` iterates **sequentially** (memory protection: only one PDF in flight), calls + `update` per lifecycle step so the component re-renders status chips, and never throws — + per-item errors are caught and written to the item. +- `BatchFileList.svelte` renders rows, emits callbacks (`onDownload(item)`, `onRemove(id)`, + `onRetry(id)`); row buttons are real buttons with accessible labels including the file name. +- Result bytes stay in memory until downloaded or removed; revocation concerns don't apply + (downloads use the existing `downloadBlob` object-URL pattern with timeout revocation). A + summary line shows total result size so users notice runaway memory situations. +- Very large runs: warn (>20 files or >25 MB per file in rasterizing tools) with a German note; + do not hard-block. + +### Retrofit pilot: compress + +- Extract per-file compression into `compressWithPreset` (already planned in + `bea-size-limit-plan.md` — coordinate: whichever ships first creates `pdf-compression.ts`). +- Page state becomes `items = $state([])` + options; the old single-result UI + (`compressedBytes` etc.) is replaced by the list. Zielgröße mode + batch: each file runs the + preset loop independently. +- Rasterization guards apply per file: files >100 MB are immediately marked as error rows with + the rasterization message instead of blocking the whole batch. + +### Rollout pattern for later tools + +- Watermark/stamp/page-numbers: identical wiring; the pdf-lib overlay loop is already per-page + and per-file load is isolated. +- Encrypt: one password for all files (password inputs stay at the top); per-file failure shows + which file broke. +- Decrypt: one shared password input + rasterization caveat once, per-file status as above. + +## Files changed + +- `src/lib/batch.ts` — new batch engine. +- `src/lib/components/BatchFileList.svelte` — new shared list component. +- `src/routes/tools/compress/+page.svelte` — pilot retrofit. +- (Phase 2/3) watermark/stamp/page-numbers/encrypt/decrypt pages. +- `tests/e2e/toolbox.spec.ts` — spec blocks per retrofitted tool. +- `agents.md` — document the batch pattern for future tools. + +## Implementation phases + +1. `batch.ts` + `BatchFileList.svelte` (build against a mock process function first). +2. Compress pilot retrofit (depends on `pdf-compression.ts` extraction). +3. E2E for compress batch: 2 PDFs → both done, ZIP contains 2 entries; one corrupt file → + error row, other still succeeds; retry works. +4. Phase 2 tools, Phase 3 tools (separate PRs). +5. Polish: copy, memory summary line, warning thresholds. + +## Verification + +- Single file dropped → identical outcome to today's single-file flow (same output bytes for the + same preset). +- Mixed success/failure batch: statuses correct, ZIP includes only successful results. +- Remove a finished row → its bytes freed (no download offered afterwards). +- Option change mid-run affects only following files. +- `pnpm check`, `pnpm lint`, `pnpm build`, `pnpm test:e2e` pass. + +## Risks + +- **Memory with many results held simultaneously**: 30 compressed PDFs × several MB can strain + low-end machines. Mitigations: per-row remove, total-size summary, and a follow-up "download + and clear" convenience. Do not stream to disk (browser constraint). +- **Sequential processing is slow for many large files** — inherent to memory safety; progress + line sets expectations. + +## Out of scope + +- Parallel processing. +- Cross-tool pipelines (compress → watermark in one run) — separate roadmap item. +- Per-file option overrides (v1 applies one option set to the whole batch). diff --git a/plan/done/bea-settings-menu-redesign-plan.md b/plan/done/bea-settings-menu-redesign-plan.md new file mode 100644 index 0000000..a4d5909 --- /dev/null +++ b/plan/done/bea-settings-menu-redesign-plan.md @@ -0,0 +1,315 @@ +# Plan: beA settings menu and “next ZIP” workflow + +**Status: Done** + +## Current review + +The loaded-archive state in `src/routes/tools/bea/+page.svelte` uses a native `
` menu fixed +in the lower-right corner. I reviewed it at 1440 × 900 and 390 × 844 with a real one-PDF ZIP. + +### What is not working + +- The floating gear is detached from the archive it controls and competes with the footer/content + edge. On mobile, the open panel overlaps the archive card. +- Two long actions are forced side by side into a 288 px panel. Their labels visibly clip to + “Archive als PDF” and “Archive”, so the difference between export and delete is unclear. +- The emoji gear does not match the app's Lucide icon language. Its accessible name is the English + word “Settings” in an otherwise German interface. +- View configuration, bulk export, and destructive session deletion are mixed without hierarchy. + “Alle ZIP-Archive löschen” receives nearly the same visual weight as export and has no + confirmation. +- “Thumbnail Breite” is an implementation-oriented, partly English label. The control should be + described as the user-visible result: “Vorschaugröße”. +- Native `
` does not provide the expected menu behavior here: no reliable outside-click + dismissal, weak focus management, and no deliberate focus return. +- Once any ZIP has loaded, the dropzone disappears. Although the route can append files internally, + the user has no control to open another ZIP or begin a clean next task. +- The loaded archive is vertically centered in a viewport-height container. This creates a large + gap below “beA-Edit” on both desktop and mobile and makes the fixed settings control feel even more + disconnected. + +## Product decision + +Use two explicit archive lifecycle actions: + +1. **“Neue ZIP bearbeiten”** is the prominent next-task action. It replaces the current workspace + only after the user has selected a valid ZIP and confirmed the replacement. This directly serves + the requested “I am done; give me the next ZIP” workflow. +2. **“Weitere ZIP hinzufügen”** remains available inside the actions panel for users intentionally + working with multiple ZIPs at once. It appends to the current workspace, preserving the behavior + already supported by `handleFilesSelected`. + +The replacement confirmation is shown _after_ file selection. Cancelling either the system picker or +confirmation must leave the current work untouched. The confirmation says that the current view and +unexported changes will be removed, while files already downloaded remain on the device. + +Do not try to infer that a user is “done” from a download click. A download does not prove that every +wanted output was saved, and the browser cannot reliably verify what happened to the file afterward. + +## UX direction + +### Desktop + +```text +← Zurück + +beA-Edit 1 ZIP · 1 Archiv geöffnet [+ Neue ZIP bearbeiten] [⚙ Einstellungen] +────────────────────────────────────────────────────────────────────────────────────────── + +┌ Current archive editor ────────────────────────────────────────────────────────────────┐ +│ archive name [Auswählen] [PDF herunterladen] │ +│ attachment previews … │ +└────────────────────────────────────────────────────────────────────────────────────────┘ +``` + +Settings popover, aligned to the right edge of the toolbar: + +```text +┌ Einstellungen ─────────────────────────────┐ +│ ANSICHT │ +│ Vorschaugröße 200 px │ +│ Klein ─────────●────────────── Groß │ +│ │ +│ ARCHIV-AKTIONEN │ +│ [+ Weitere ZIP hinzufügen] │ +│ [⇩ Alle Archive als PDF herunterladen] │ shown only when useful +│ ───────────────────────────────────────── │ +│ [🗑 Aktuelle Bearbeitung leeren] │ quiet destructive action +└───────────────────────────────────────────┘ +``` + +### Mobile + +The toolbar becomes a two-row block immediately below the title and remains in normal document flow. +The status occupies the first row; the two buttons share the second row. The popover uses +`width: min(22rem, calc(100vw - 2rem))`, stacks every action vertically, and relies on collision-aware +positioning rather than fixed bottom/right coordinates. No control or panel may cover the archive +header. + +The loaded workspace becomes top-aligned (`justify-start`) instead of viewport-centered, removing the +large empty band between the page heading and first archive. + +## Visual system + +Preserve the existing Nominandum identity rather than introducing a new theme: + +- **Ink** `#122F62`: headings, primary actions, and active controls. +- **Workspace** `#EEF4F6`: page background. +- **Paper** `#FFFFFF`: editor and popover surfaces. +- **Handoff coral** `#EA544B`: restrained accent only; do not use it to make deletion look primary. +- **Success** `#4E8A72`: completed processing state. +- Typography remains **Nunito** for interface copy; use the existing Geist utility face only where + numeric/file metadata already uses it. +- Use existing radii and semantic CSS variables from `src/routes/layout.css`; no theme-level CSS + changes should be necessary. + +The distinctive element is the **archive handoff row**: current-work status on the left and “Neue ZIP +bearbeiten” on the right. It expresses the actual legal-document rhythm—finish one beA export, hand +it off, open the next—rather than presenting a generic floating settings cog. + +Self-critique: a card full of settings tabs would be a generic dashboard solution and is unnecessary +for one view preference. The revised direction removes the floating action, keeps the next-case task +visible, and spends visual emphasis only on the archive handoff. + +## Interaction details + +### Toolbar + +- Status copy derives from state: + - `1 ZIP · 1 Archiv geöffnet` + - `2 ZIPs · 3 Archive geöffnet` + - while work is pending, append `· 1 wird geöffnet` in an `aria-live="polite"` status. +- “Neue ZIP bearbeiten” uses Lucide `FilePlus2` and remains available after the first archive or an + error. It opens a ZIP-only file picker. +- “Einstellungen” uses Lucide `Settings2`, has a visible German label at desktop sizes, and retains + an explicit accessible name at all widths. +- Do not make the toolbar fixed or sticky in v1. Normal flow avoids covering previews and footer + content; sticky behavior can be reconsidered after testing long archives. + +### New ZIP replacement + +1. User activates “Neue ZIP bearbeiten”. +2. The system file picker opens and accepts the same ZIP types as `ZipDropzone`. +3. If the picker is cancelled, nothing changes. +4. If valid files are selected, show an `AlertDialog`: + - title: **“Aktuelle Bearbeitung ersetzen?”** + - description: **“Archive und nicht heruntergeladene Änderungen in dieser Ansicht werden + entfernt. Bereits heruntergeladene PDFs bleiben erhalten.”** + - cancel: **“Abbrechen”** + - confirm: **“Neue ZIP öffnen”** +5. On confirm, increment `archiveGeneration`, clear selected files, processed archives, and jobs, then + enqueue the newly selected files against the new generation. +6. If extraction fails, show the existing processing error state for the new ZIP and allow retry or + removal; do not restore the old workspace implicitly. + +Keep the selected replacement files in temporary component state until confirmation. This prevents a +cancelled dialog from mutating or clearing the current workspace. Reset the underlying file input +after every selection so choosing the same ZIP again still fires `change`. + +### Append, export, and clear + +- “Weitere ZIP hinzufügen” uses the existing append path and never clears current archives. +- Show bulk export only when there are at least two processed archives; one archive already has its + own clearer “PDF herunterladen” action. +- Rename bulk export to **“Alle Archive als PDF herunterladen”**. +- Rename deletion to **“Aktuelle Bearbeitung leeren”** and place it after a separator as a quiet + destructive menu item with `Trash2`. +- Clearing requires its own `AlertDialog`; confirmation calls the existing reset path and returns the + user to the initial ZIP dropzone. +- Disable replacement, append, export, and clear while a destructive transition is being committed, + but allow a new selection while archive extraction is in progress; generation guarding prevents + stale results from returning. + +### View setting + +- Replace the raw range with the shadcn `Slider` labeled **“Vorschaugröße”**. +- Keep the current `100–400 px` range, `1 px` step, clamp function, and `thumbnailWidth` + localStorage key for backward compatibility. +- Display the current value as tabular numerals and include “Klein”/“Groß” endpoint hints. +- The label and current value must be associated with the slider for assistive technology. Arrow-key + adjustment remains available. + +## Component and state design + +### New `BeaWorkspaceControls.svelte` + +Create `src/lib/components/BeaWorkspaceControls.svelte` to keep the already state-heavy route from +absorbing menu markup. Suggested props: + +```ts +type Props = { + selectedZipCount: number; + processedArchiveCount: number; + pendingZipCount: number; + thumbnailWidth: number; + onThumbnailWidthChange: (value: number) => void; + onRequestReplacement: () => void; + onAppendFiles: (files: File[]) => void; + onExportAll: () => void; + onClear: () => void; +}; +``` + +The component owns only ephemeral UI state (popover/dialog open state and temporarily selected +replacement files). The route remains the source of truth for files, jobs, archives, and the +persisted thumbnail width. + +Centralize the ZIP accept string and validation used by the initial dropzone and both compact pickers. +Prefer extracting a small reusable ZIP picker/input rather than duplicating MIME and extension checks +inside the controls component. + +### Route changes + +In `src/routes/tools/bea/+page.svelte`: + +- Replace `deleteAllZipArchives` with a single `resetWorkspace()` helper that increments + `archiveGeneration` and clears all three state arrays. +- Add `replaceWithZipFiles(files)` that calls `resetWorkspace()` and then + `handleFilesSelected(files)`; the new jobs must capture the incremented generation. +- Keep `handleFilesSelected` as the append operation. +- Render `BeaWorkspaceControls` whenever at least one selected file/job/archive exists. +- Remove the fixed `
` block. +- Top-align the loaded-workspace container and render status/controls before processing and editor + cards. +- Preserve `thumbnailWidth` hydration and persistence exactly as today. + +### shadcn-svelte pieces + +Add only the required official components with the pnpm CLI, reviewing generated files and imports: + +- `popover` +- `slider` +- `separator` +- `alert-dialog` + +Do not overwrite the customized button component. The project currently imports +`$lib/components/ui/button.svelte` directly, so adapt generated imports to the local export shape if +the registry assumes a barrel. Use Lucide Svelte icons (`FilePlus2`, `Settings2`, `Download`, +`Trash2`) with `data-icon` inside buttons. + +## Files + +- `src/routes/tools/bea/+page.svelte` — workspace lifecycle, reset/replace handlers, and layout. +- `src/lib/components/BeaWorkspaceControls.svelte` — new toolbar, popover, dialogs, and picker + coordination. +- `src/lib/components/ZipDropzone.svelte` and/or a new small ZIP picker helper — shared accept and + selection behavior. +- `src/lib/components/ui/{popover,slider,separator,alert-dialog}/` — official shadcn-svelte source. +- `tests/e2e/toolbox.spec.ts` — archive handoff, menu, keyboard, and responsive coverage. +- `agents.md` — document append versus replace semantics and the shared reset invariant. + +No changes are expected in `zip-processing.ts`, archive transforms, export ordering, or server code. + +## Implementation phases + +1. **Shared ZIP selection and state helpers** + - Centralize accepted ZIP types. + - Introduce `resetWorkspace` and `replaceWithZipFiles`. + - Verify generation guarding when replacement occurs during extraction. +2. **Workspace toolbar and layout** + - Add the top-aligned status/action row. + - Implement the replacement picker and confirmation without removing current work on cancel. + - Remove the fixed gear and viewport-centering. +3. **Settings popover** + - Add the responsive popover, slider, append, conditional bulk export, and confirmed clear. + - Verify keyboard focus, Escape/outside-click dismissal, and focus return. +4. **Tests and polish** + - Add e2e coverage below. + - Run desktop/mobile browser review and remove any clipped or overlapping labels. + - Update `agents.md` and run the full validation set. + +## Verification + +### Automated + +Add Playwright cases for: + +- Load ZIP A → “Neue ZIP bearbeiten” appears → select ZIP B → cancel confirmation → ZIP A remains. +- Repeat replacement → confirm → only ZIP B is shown and no late result from ZIP A can reappear. +- Cancel the system file picker → workspace is unchanged. +- Select the same ZIP twice through replacement → the second `change` is processed. +- “Weitere ZIP hinzufügen” appends ZIP B and both archive editors remain visible. +- Bulk export is absent for one processed archive and available for two or more. +- “Aktuelle Bearbeitung leeren” cancel preserves state; confirm returns to the initial dropzone. +- Slider changes preview width, works with arrow keys, and survives a reload through localStorage. +- Settings opens with a German accessible name, closes on Escape/outside click, and returns focus to + its trigger. +- At 390 × 844, toolbar actions and every popover label fit without horizontal clipping; the panel + stays inside the viewport and does not cover the archive header. + +Then run: + +```sh +pnpm check +pnpm test:e2e +pnpm lint +pnpm build +``` + +### Manual + +- Review 1440 × 900 and 390 × 844 with one archive, multiple archives, an extraction in progress, + and an extraction error. +- Confirm file processing remains entirely local and no case data is persisted. +- Confirm tab order is title/status → new ZIP → settings → popover controls → archive controls. +- Confirm reduced-motion mode introduces no menu animation requirement. + +## Risks and mitigations + +- **Accidental loss during replacement:** current state is not cleared until valid files are selected + and the user confirms. +- **Stale async extraction:** every reset increments `archiveGeneration`; old jobs cannot append + results afterward. +- **Duplicate picker logic:** centralize accept/filter/reset behavior before adding compact actions. +- **Component bloat for a small menu:** use one popover and shared alert-dialog primitives; do not add + tabs, a permanent sidebar, or a settings route. +- **Ambiguous “new” versus “additional”:** keep the labels distinct and stable everywhere: + “Neue ZIP bearbeiten” replaces; “Weitere ZIP hinzufügen” appends. + +## Out of scope + +- Persisting archives or edits across reloads. +- Detecting whether the operating system completed a download. +- Undoing a confirmed workspace replacement. +- Changing PDF composition, attachment ordering, or sub-document behavior. +- A global application settings page. diff --git a/plan/done/metadata-plan.md b/plan/done/metadata-plan.md new file mode 100644 index 0000000..6fa79b3 --- /dev/null +++ b/plan/done/metadata-plan.md @@ -0,0 +1,139 @@ +# Plan: PDF-Metadaten — anzeigen und bereinigen + +## Goal + +Add a new tool **"Metadaten"** (`/tools/metadata`) with two jobs: + +1. **Inspect** a PDF: show document metadata (title, author, subject, keywords, creator, + producer, creation/modification dates), page count, file size, and encryption state — + lawyers need to know what a document reveals before forwarding it. +2. **Clean** a PDF: strip the metadata fields and re-save, so the cleaned file can be shared + without leaking software, author names, or edit history. + +No rasterization is involved — this tool works directly with pdf-lib and preserves the document +fully (vector text stays selectable). No new dependencies. + +--- + +## Open decisions (defaults marked ✅) + +### D1 — Cleaning strategy + +- ✅ **A. Neutralize fields + re-save with pdf-lib.** Set title/author/subject/keywords to empty + strings, creator/producer to neutral values, reset creation/modification dates to the cleaning + moment. Saving through pdf-lib rebuilds the file without unreferenced objects. Simple and + dependency-free. +- B. Also attempt to remove embedded files/attachments. pdf-lib has no reliable public API to + enumerate or remove all embedded-file entries; defer to D2. + +### D2 — Embedded-file detection + +- ✅ **A. Best-effort heuristic warning.** Scan the raw bytes for `/EmbeddedFile` and + `/EmbeddedFiles` markers; if found, show "Die Datei enthält möglicherweise eingebettete + Anlagen. Diese werden beim Bereinigen nicht entfernt." No removal in v1. +- B. No detection at all. Rejected: silent ignorance is worse than an honest warning. + +### D3 — Encrypted PDFs + +- ✅ **A. Friendly rejection.** pdf-lib cannot load encrypted files; show a German hint pointing + to `tools/decrypt` first. No silent failure. + +--- + +## UX + +- Route `/tools/metadata`, new entry in `src/lib/tools.ts`: + `{ slug: 'metadata', title: 'Metadaten', description: 'Zeigen Sie Metadaten eines PDFs an und entfernen Sie sie.', icon: 'info' }` + - icon mapping on the start page. +- Flow: `PdfDropzone` (single PDF) → results card with: + - **Info table** (label/value rows, empty values shown as "–"): Titel, Autor, Betreff, + Schlüsselwörter, Erstellt mit (creator), PDF-Erzeuger (producer), Erstellt am, Geändert am, + Seiten, Größe, Verschlüsselung (Ja/Nein — from load success/failure is insufficient; use + pdf-lib's load error for encrypted detection and show it as a state, or report "Unbekannt" + when not determinable). + - **Embedded-file warning block** when the heuristic fires (amber styling consistent with + existing warning patterns). + - **Quality note**: "Beim Bereinigen bleibt der Inhalt vollständig erhalten (kein Qualitätsverlust)." +- Actions: + - **"Bereinigt herunterladen"** (primary, disabled for encrypted files) → cleaned PDF named + `{baseName}_bereinigt.pdf`. + - Secondary: "Original herunterladen" is _not_ offered (the user already has the original). +- German inline error area for corrupt/encrypted files, matching existing tool pages. + +## Technical approach + +New route `src/routes/tools/metadata/+page.svelte` plus a small lib module +`src/lib/pdf-metadata.ts`: + +```ts +export type PdfMetadataInfo = { + title: string; + author: string; + subject: string; + keywords: string[]; + creator: string; + producer: string; + creationDate: Date | null; + modificationDate: Date | null; + pageCount: number; + embeddedFileSuspected: boolean; +}; + +export const readPdfMetadata = async (bytes: Uint8Array): Promise; +export const stripPdfMetadata = async (bytes: Uint8Array): Promise; +``` + +- `readPdfMetadata`: `PDFDocument.load(bytes)` (with `{ ignoreEncryption: false }` default so + encrypted files throw), then the `getTitle()`/`getAuthor()`/`getSubject()`/`getKeywords()`/ + `getCreator()`/`getProducer()`/`getCreationDate()`/`getModificationDate()`/`getPageCount()` + accessors. Encryption detection: catch the pdf-lib `EncryptedPDFError` and return a distinct + `{ encrypted: true }` result shape (discriminated union) so the UI can branch. +- `stripPdfMetadata`: load, apply setters (`setTitle('')`, `setAuthor('')`, `setSubject('')`, + `setKeywords([])`, `setCreator('')`, `setProducer('beA-Edit')`, `setCreationDate(new Date())`, + `setModificationDate(new Date())`), `save()`. +- Embedded-file heuristic: decode bytes with `TextDecoder` once and search for the two marker + strings; cheap, runs before pdf-lib load. +- Reading metadata does **not** need pdfjs or thumbnails — no rasterization limits apply; large + files are fine because pdf-lib parses lazily enough for this purpose. + +## Files changed + +- `src/lib/pdf-metadata.ts` — new. +- `src/routes/tools/metadata/+page.svelte` — new route. +- `src/lib/tools.ts`, `src/routes/+page.svelte` — registry entry + icon map. +- `tests/e2e/toolbox.spec.ts` — new spec block. +- `agents.md` — structure update. + +## Implementation phases + +1. `pdf-metadata.ts` with read + strip + heuristic. +2. Route UI: info table, warning block, clean action, encrypted-file branch. +3. Registry entry + icon. +4. Playwright tests: PDF created with `createPdf` + set metadata fields → fields displayed; + clean download loads with pdf-lib and has empty author/title; encrypted-PDF rejection hint + (reuse the encrypt tool to produce a fixture inline). +5. Polish, German copy review, `pnpm check`/`lint`/`build`/`test:e2e`. + +## Verification + +- Metadata of a known PDF renders completely; empty fields show "–". +- Cleaned PDF: open in viewer, metadata fields empty; content and page count identical; file + still selectable/searchable (no rasterization happened). +- Encrypted input shows the decrypt hint instead of crashing. +- `/EmbeddedFile`-containing fixture triggers the amber warning. +- Standard command gauntlet passes. + +## Risks + +- **XMP metadata**: pdf-lib's setters touch the Info dict; a separate XMP metadata stream can + survive. Mitigation: note in the UI copy that cleaning covers standard document properties. + (Full XMP removal could be a follow-up by deleting the metadata stream object.) +- **Producer fingerprint**: setting `beA-Edit` as producer is itself a small fingerprint; neutral + but acceptable — alternative is an empty string; pick empty string if reviewers prefer. + +## Out of scope + +- Removing embedded files/attachments (see D2). +- XMP stream removal (follow-up). +- Metadata editing (rename title, etc.) — inspection + removal only. +- Hooking metadata cleaning into the beA export pipeline. diff --git a/plan/done/rotate-plan.md b/plan/done/rotate-plan.md new file mode 100644 index 0000000..6b57816 --- /dev/null +++ b/plan/done/rotate-plan.md @@ -0,0 +1,140 @@ +# Plan: Seiten drehen + +## Goal + +Add a new tool **"Seiten drehen"** (`/tools/rotate`) that fixes page orientation — the classic +case being scanned court mail with individual pages rotated 90°/180°. Users rotate pages +individually or all at once; the result is saved via the standard PDF `/Rotate` mechanism, so the +operation is **lossless and instant** (no rasterization, no re-encoding). + +No new dependencies. + +--- + +## Open decisions (defaults marked ✅) + +### D1 — How rotation is applied + +- ✅ **A. pdf-lib `setRotation` (document-level `/Rotate`).** `page.setRotation(degrees(angle))` + writes the page's rotation entry; every viewer honors it. Lossless, fast, file size unchanged. + Existing rotation of a page (from scanners that already set `/Rotate`) is read via + `page.getRotation().angle` and added to, so the user's delta is always applied on top of the + current state. +- B. Rasterize-and-rotate pixels. Unnecessary quality loss and memory cost; rejected. + +### D2 — Grid capabilities + +- ✅ **Extend `PdfPageGrid` with an optional action snippet.** The current grid renders fixed + cards. Add an optional snippet prop (e.g. `pageActions`) rendered below each thumbnail; existing + call sites (separate, watermark, extract) pass nothing and look unchanged. The rotate page uses + it for per-page rotate buttons and a rotation badge. +- B. Fork the grid inside the rotate page. Rejected: duplicates the accessible grid work. + +--- + +## UX + +- Route `/tools/rotate`, new `src/lib/tools.ts` entry: + `{ slug: 'rotate', title: 'Seiten drehen', description: 'Drehen Sie einzelne oder alle Seiten eines PDFs.', icon: 'rotate-cw' }` + - icon map entry. +- After load (`loadPdfDocument` + thumbnails like separate): + - Quick-action bar: **"Alle 90° rechts"**, **"Alle 90° links"**, **"Alle 180°"**, + **"Zurücksetzen"** (clears all deltas). + - Page grid: each card shows the thumbnail with a CSS rotation preview + (`transform: rotate({total}deg)` on the ``, contained in a square-ish frame so rotated + pages still fit), a small badge when the total rotation is non-zero (e.g. "90°"), and two + icon buttons (rotate left / rotate right) provided through the new `pageActions` snippet. + Buttons are real `