diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5ac62ae --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.git +.gitignore +.idea +.vscode +.svelte-kit +build +node_modules +npm-debug.log* +pnpm-debug.log* +.env +.env.* diff --git a/.gitea/workflows/prod.yml b/.gitea/workflows/prod.yml new file mode 100644 index 0000000..6bdc57e --- /dev/null +++ b/.gitea/workflows/prod.yml @@ -0,0 +1,37 @@ +name: Build and Push Docker Image + +on: + push: + branches: + - main + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Docker login to Gitea Registry + run: | + echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login "${{ vars.REGISTRY_URL }}" \ + -u "${{ secrets.REGISTRY_USER }}" \ + --password-stdin + + - name: Build Docker image + run: | + docker build \ + -t "${{ vars.REGISTRY_URL }}/${{ vars.IMAGE_NAME }}:latest" \ + -t "${{ vars.REGISTRY_URL }}/${{ vars.IMAGE_NAME }}:${{ gitea.sha }}" \ + . + + - name: Push Docker image + run: | + docker push "${{ vars.REGISTRY_URL }}/${{ vars.IMAGE_NAME }}:latest" + docker push "${{ vars.REGISTRY_URL }}/${{ vars.IMAGE_NAME }}:${{ gitea.sha }}" + + - name: Deploy + run: | + curl https://dokploy.robosoft-solutions.de/api/deploy/compose/vETS7jxOixux3wDPobAaL diff --git a/.idea/workspace.xml b/.idea/workspace.xml index bbf37cc..1cc5ebd 100644 --- a/.idea/workspace.xml +++ b/.idea/workspace.xml @@ -1,36 +1,108 @@ + + + + + + + - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + { "associatedIndex": 0, "fromUser": false @@ -43,26 +115,31 @@ - { - "keyToString": { - "ModuleVcsDetector.initialDetectionPerformed": "true", - "RunOnceActivity.ShowReadmeOnStart": "true", - "RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true", - "RunOnceActivity.cidr.known.project.marker": "true", - "RunOnceActivity.git.unshallow": "true", - "RunOnceActivity.readMode.enableVisualFormatting": "true", - "RunOnceActivity.typescript.service.memoryLimit.init": "true", - "cidr.known.project.marker": "true", - "codeWithMe.voiceChat.enabledByDefault": "false", - "git-widget-placeholder": "features/shadcn", - "last_opened_file_path": "/home/rehlert/repos/juri-merger", - "list.type.of.created.stylesheet": "CSS", - "nodejs_package_manager_path": "pnpm", - "settings.editor.selected.configurable": "preferences.pluginManager", - "ts.external.directory.path": "/home/rehlert/repos/juri-merger/node_modules/typescript/lib", - "vue.rearranger.settings.migration": "true" + +}]]> @@ -76,7 +153,13 @@ - + + + + + + + - @@ -182,6 +281,17 @@ + + + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f534bb2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,29 @@ +# syntax=docker/dockerfile:1 + +FROM node:24-alpine AS base +ENV PNPM_HOME="/pnpm" +ENV PATH="$PNPM_HOME:$PATH" +RUN corepack enable && corepack prepare pnpm@11.15.1 --activate + +FROM base AS dependencies +WORKDIR /app +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./ +RUN pnpm install --frozen-lockfile + +FROM dependencies AS build +COPY . . +RUN pnpm build && pnpm prune --prod --ignore-scripts + +FROM node:24-alpine AS runtime +WORKDIR /app +ENV NODE_ENV="production" +ENV HOST="0.0.0.0" +ENV PORT="3000" + +COPY --from=build --chown=node:node /app/package.json ./ +COPY --from=build --chown=node:node /app/node_modules ./node_modules +COPY --from=build --chown=node:node /app/build ./build + +USER node +EXPOSE 3000 +CMD ["node", "build/index.js"] diff --git a/agents.md b/agents.md index 8be9a8f..d537fb9 100644 --- a/agents.md +++ b/agents.md @@ -1,26 +1,47 @@ -# Scout findings +# Project notes for agents ## Project snapshot -- **Tech stack:** SvelteKit + TypeScript + Vite + Tailwind v4. -- **App name/branding:** `beA-Edit`. +- **Repo name:** `juri-merger` +- **App name/branding:** `beA-Edit`, published by Nominandum GmbH. +- **Tech stack:** SvelteKit + Svelte 5 (runes) + TypeScript + Vite + Tailwind v4 (CSS-first). +- **UI system:** shadcn-svelte (style `nova`, lucide icons) layered on a custom "nom" theme via CSS variables in `src/routes/layout.css`. - **Core purpose:** upload one or more ZIP files, extract relevant PDFs/images, preview them, optionally reorder attachments, and download a merged PDF. +- **Active branch:** `features/shadcn` (shadcn-svelte integration in progress). ## Main user flow 1. User adds ZIP files via drag/drop or file picker (`ZipDropzone`). -2. `+page.svelte` starts async extraction for each file. +2. `+page.svelte` starts async extraction for each file (tracked via `archiveGeneration` so stale in-flight extractions are discarded on bulk delete). 3. Each ZIP is processed into one or more `ProcessedZipArchive` entries. 4. `ProcessedZipArchiveEditor` shows thumbnails, allows reordering, and can download the merged PDF. 5. `AttachmentPreview` renders image previews directly and PDF previews via `pdfjs-dist`. +6. A fixed bottom-right settings drawer (`
`) exposes **export all** and **delete all** archive actions plus the thumbnail-width slider. ## Important source files -- `src/routes/+page.svelte` — top-level page state, file selection, localStorage-backed thumbnail width setting, async ZIP processing. +- `src/routes/+page.svelte` — top-level page state, file selection, localStorage-backed thumbnail width, async ZIP processing, bulk export/delete, settings drawer. +- `src/routes/+layout.svelte` — app shell: header with logo and footer with Nominandum GmbH legal/imprint block. +- `src/routes/layout.css` — Tailwind v4 entrypoint; imports `tw-animate-css`, `shadcn-svelte/tailwind.css`, Geist + Nunito fonts; defines the semantic token layer (`--background`, `--primary`, `--secondary`, `--accent`, `--destructive`, `--radius`, etc.) mapping shadcn tokens onto the nom palette, including a `.dark` override. - `src/lib/zip-processing.ts` — archive extraction, recursive nested ZIP handling, metadata ordering, PDF merge/export, image conversion. - `src/lib/services/zip-inflating.service.ts` — thin wrapper around `fflate` unzip. - `src/lib/services/xml-reading.service.ts` — parses XJustiz XML metadata (`sender`, `receiver`, `documentNames`). - `src/lib/components/ZipDropzone.svelte` — file picker + drag/drop ZIP intake. -- `src/lib/components/ProcessedZipArchiveEditor.svelte` — archive editor, drag reordering, download button. +- `src/lib/components/ProcessedZipArchiveEditor.svelte` — archive editor (name input, drag reordering, per-archive PDF download, selection mode + sub-document management, export-mode switch). +- `src/lib/components/SubDocumentEditor.svelte` — sub-document block editor (editable name, count, dissolve action, draggable header for block reorder, internal horizontal reorderable attachment list). - `src/lib/components/AttachmentPreview.svelte` — preview generation for images/PDFs. -- `src/lib/components/Card.svelte` — generic page container. +- `src/lib/components/ui/button.svelte` — shadcn-backed button atom (see "Button system" below). +- `src/lib/components/ui/card/` — shadcn card composition (`Root/Content/Header/Title/Description/Footer/Action`, re-exported as `Card.*`). +- `src/lib/actions/ripple.ts` — Svelte action that renders a click ripple animation on an element. +- `src/lib/components/icons/` — hand-rolled SVG icon components (`IconSettings`, `IconFile`, `IconMenu`, `IconNeedle`, `IconOffice`, `IconPerson`, `IconTag`, `IconTrash`, `IconChain`, `IconUnknown`). If an icon is missing, report it, and we see if we can create +- `src/lib/utils.ts` — shared `cn()` helper (clsx + tailwind-merge) and `WithElementRef`/`WithoutChild` typing helpers used by shadcn components. + +## Button system +`src/lib/components/ui/button.svelte` is a shadcn-svelte–style primitive adapted to the nom theme: +- Built with `tailwind-variants` (`buttonVariants`) and the shared `cn()` helper. +- Variants: `primary`, `secondary` (gradient `secondary → --secondary-end`), `bordered`, `tertiary`, `success`. +- Sizes: `standard` (h-8, rounded-16px), `large` (h-12, rounded-24px). +- Renders either a ` +
+ + + {#if hasSubDocuments} +
+ {#each [{ value: 'single', label: 'Eine PDF' }, { value: 'separate', label: 'Getrennt' }] as option (option.value)} + + {/each} +
+ {/if} + + +
{#if mergeError} @@ -167,38 +400,103 @@ {/if} -
    - {#each attachments as attachment, index (attachment.path)} -
  • handleDragStart(index, event)} - ondragover={(event) => handleDragOver(index, event)} - ondrop={(event) => handleDrop(index, event)} - ondragend={handleDragEnd} - > -
    - + {#if selectionMode && selectedPaths.size > 0} +
    + {selectedPaths.size} Datei{selectedPaths.size === 1 ? '' : 'en'} ausgewählt + +
    + {/if} -
    - {attachment.name} +
      + {#each looseAttachments as attachment, index (attachment.path)} +
    • handleLooseDragStart(index, event)} + ondragover={(event) => handleLooseDragOver(index, event)} + ondrop={(event) => handleLooseDrop(index, event)} + ondragend={handleLooseDragEnd} + > + {#if selectionMode} + + {:else} +
      + + +
      + {attachment.name} +
      -
    - + + {/if}
  • {/each}
+ + {#if looseAttachments.length === 0 && subDocuments.length === 0} +
+ Keine Dateien in diesem Archiv. Lade ein anderes ZIP, um weiterzuarbeiten. +
+ {/if} + + {#if hasSubDocuments} +
+ {#each subDocuments as subDocument, blockIndex (subDocument.id)} + dissolveSubDocumentBlock(subDocument.id)} + onAttachmentMove={handleAttachmentMove} + onBlockDragStart={handleBlockDragStart} + onBlockDragOver={handleBlockDragOver} + onBlockDrop={handleBlockDrop} + onBlockDragEnd={handleBlockDragEnd} + /> + {/each} +
+ {/if} diff --git a/src/lib/components/SubDocumentEditor.svelte b/src/lib/components/SubDocumentEditor.svelte new file mode 100644 index 0000000..81164fe --- /dev/null +++ b/src/lib/components/SubDocumentEditor.svelte @@ -0,0 +1,286 @@ + + +
+
+ + + + + + {attachments.length} Datei{attachments.length === 1 ? '' : 'en'} + + + +
+ + {#if attachments.length === 0} +
    { + if (event.dataTransfer?.types.includes(ATTACHMENT_MIME)) { + event.preventDefault(); + } + }} + ondrop={(event) => { + const source = parseAttachmentPayload(event); + + if (!source) { + return; + } + + event.preventDefault(); + + const target: AttachmentLocation = { + kind: 'subDocument', + id: subDocument.id, + index: 0 + }; + + onAttachmentMove(source, target); + dragIndex = null; + dropIndex = null; + }} + > +
  • + Dieses Teildokument ist leer. Ziehe Dateien hierher, um sie hinzuzufügen. +
  • +
+ {:else} +
    + {#each attachments as attachment, index (attachment.path)} +
  • handleCardDragStart(index, event)} + ondragover={(event) => handleCardDragOver(index, event)} + ondrop={(event) => handleCardDrop(index, event)} + ondragend={handleCardDragEnd} + > +
    + + +
    + {attachment.name} +
    +
    + +
  • + {/each} +
+ {/if} +
diff --git a/src/lib/zip-processing.ts b/src/lib/zip-processing.ts index a0f6645..2137660 100644 --- a/src/lib/zip-processing.ts +++ b/src/lib/zip-processing.ts @@ -12,11 +12,33 @@ export type ZipAttachment = { data: Uint8Array; }; -export type ProcessedZipArchive = { +export type SubDocument = { + id: string; name: string; attachments: ZipAttachment[]; }; +export type ProcessedZipArchive = { + name: string; + attachments: ZipAttachment[]; + subDocuments: SubDocument[]; +}; + +export type ExportMode = 'single' | 'separate'; + +export type ExportUnit = { + name: string; + bytes: Uint8Array; +}; + +/** + * Drag source/target descriptors shared between the archive editor and the + * sub-document editor so HTML5 `dataTransfer` payloads stay consistent. + */ +export type AttachmentLocation = + | { kind: 'loose'; index: number } + | { kind: 'subDocument'; id: string; index: number }; + const IMAGE_EXTENSIONS = new Set([ '.avif', '.bmp', @@ -172,7 +194,8 @@ const extractZipEntries = async (archiveBytes: Uint8Array): Promise 0) { archives.push({ name: archiveName, - attachments: orderedAttachments + attachments: orderedAttachments, + subDocuments: [] }); } @@ -283,16 +306,323 @@ const mergeAttachmentIntoPdf = async (mergedPdf: PDFDocument, attachment: ZipAtt } }; -export const mergeProcessedZipArchive = async (archive: ProcessedZipArchive) => { - if (archive.attachments.length === 0) { - throw new Error('Cannot create a PDF from an empty archive.'); +export const mergeAttachments = async (attachments: ZipAttachment[]): Promise => { + if (attachments.length === 0) { + throw new Error('Cannot create a PDF from an empty attachment list.'); } const mergedPdf = await PDFDocument.create(); - for (const attachment of archive.attachments) { + for (const attachment of attachments) { await mergeAttachmentIntoPdf(mergedPdf, attachment); } return mergedPdf.save(); }; + +export const mergeProcessedZipArchive = async (archive: ProcessedZipArchive) => { + if (archive.attachments.length === 0 && archive.subDocuments.length === 0) { + throw new Error('Cannot create a PDF from an empty archive.'); + } + + return mergeAttachments(flattenArchiveForMerge(archive)); +}; + +/** + * Flatten the archive into a single ordered attachment list, preserving + * sub-document order first and appending loose attachments at the end. + */ +export const flattenArchiveForMerge = (archive: ProcessedZipArchive): ZipAttachment[] => { + const ordered: ZipAttachment[] = []; + + for (const subDocument of archive.subDocuments) { + ordered.push(...subDocument.attachments); + } + + ordered.push(...archive.attachments); + + return ordered; +}; + +/** + * Create a new sub-document with a stable id from a list of attachments. + */ +export const createSubDocument = (name: string, attachments: ZipAttachment[]): SubDocument => ({ + id: crypto.randomUUID(), + name, + attachments: attachments.map((attachment) => ({ ...attachment })) +}); + +/** + * Reorder sub-documents inside an archive. Matches the semantics of a single + * `splice(fromIndex, 1)` -> `splice(toIndex, 0, moved)` move. + */ +export const moveSubDocument = ( + archive: ProcessedZipArchive, + fromIndex: number, + toIndex: number +): ProcessedZipArchive => { + if ( + fromIndex === toIndex || + fromIndex < 0 || + toIndex < 0 || + fromIndex >= archive.subDocuments.length || + toIndex >= archive.subDocuments.length + ) { + return archive; + } + + const nextSubDocuments = [...archive.subDocuments]; + const [movedSubDocument] = nextSubDocuments.splice(fromIndex, 1); + + nextSubDocuments.splice(toIndex, 0, movedSubDocument); + + return { ...archive, subDocuments: nextSubDocuments }; +}; + +const removeAt = (list: T[], index: number): T | null => { + if (index < 0 || index >= list.length) { + return null; + } + + const [item] = list.splice(index, 1); + + return item ?? null; +}; + +const insertAt = (list: T[], index: number, item: T) => { + const clampedIndex = Math.max(0, Math.min(index, list.length)); + list.splice(clampedIndex, 0, item); +}; + +/** + * Reorder an attachment within its current location (loose list or a single + * sub-document's attachment list). Used by the per-list drag handlers. + */ +export const moveAttachmentInPlace = ( + archive: ProcessedZipArchive, + location: AttachmentLocation, + toIndex: number +): ProcessedZipArchive => { + if (location.kind === 'loose') { + const nextAttachments = [...archive.attachments]; + const moved = removeAt(nextAttachments, location.index); + + if (!moved) { + return archive; + } + + insertAt(nextAttachments, toIndex, moved); + + return { ...archive, attachments: nextAttachments }; + } + + let changed = false; + + const nextSubDocuments = archive.subDocuments.map((subDocument) => { + if (subDocument.id !== location.id) { + return subDocument; + } + + const nextAttachments = [...subDocument.attachments]; + const moved = removeAt(nextAttachments, location.index); + + if (!moved) { + return subDocument; + } + + insertAt(nextAttachments, toIndex, moved); + changed = true; + + return { ...subDocument, attachments: nextAttachments }; + }); + + return changed ? { ...archive, subDocuments: nextSubDocuments } : archive; +}; + +/** + * Move an attachment from a source location to a target location. Either side + * may be the loose list or any sub-document. The attachment moves (not copies), + * so the total attachment count is invariant. When source and target point to + * the same list, this degenerates to an in-place reorder (explicitly delegated + * to `moveAttachmentInPlace` so the indices are normalized correctly). + */ +export const moveAttachmentIntoSubDocument = ( + archive: ProcessedZipArchive, + source: AttachmentLocation, + target: AttachmentLocation +): ProcessedZipArchive => { + const sameList = + source.kind === 'loose' && target.kind === 'loose' + ? true + : source.kind === 'subDocument' && target.kind === 'subDocument' + ? source.id === target.id + : false; + + if (sameList) { + return moveAttachmentInPlace(archive, source, target.index); + } + + const nextSubDocuments = archive.subDocuments.map((subDocument) => ({ + ...subDocument, + attachments: [...subDocument.attachments] + })); + const looseAttachments = [...archive.attachments]; + + const removeFromList = (location: AttachmentLocation): ZipAttachment | null => { + if (location.kind === 'loose') { + return removeAt(looseAttachments, location.index); + } + + const target_ = nextSubDocuments.find((item) => item.id === location.id); + + return target_ ? removeAt(target_.attachments, location.index) : null; + }; + + const insertIntoList = (attachment: ZipAttachment, location: AttachmentLocation) => { + if (location.kind === 'loose') { + insertAt(looseAttachments, location.index, attachment); + return; + } + + const target_ = nextSubDocuments.find((item) => item.id === location.id); + + if (!target_) { + // fall back to loose list end if the referenced sub-document vanished + looseAttachments.push(attachment); + return; + } + + insertAt(target_.attachments, location.index, attachment); + }; + + const movedAttachment = removeFromList(source); + + if (!movedAttachment) { + return archive; + } + + insertIntoList(movedAttachment, target); + + return { + ...archive, + attachments: looseAttachments, + subDocuments: nextSubDocuments + }; +}; + +/** + * Remove a single attachment from a sub-document and return it to the end of + * the loose list. Useful as a targeted "move back to loose" action. + */ +export const removeAttachmentFromSubDocument = ( + archive: ProcessedZipArchive, + subDocumentId: string, + attachmentIndex: number +): ProcessedZipArchive => { + const nextSubDocuments = archive.subDocuments.map((subDocument) => ({ + ...subDocument, + attachments: [...subDocument.attachments] + })); + const nextLooseAttachments = [...archive.attachments]; + + const target_ = nextSubDocuments.find((item) => item.id === subDocumentId); + + if (!target_) { + return archive; + } + + const moved = removeAt(target_.attachments, attachmentIndex); + + if (!moved) { + return archive; + } + + nextLooseAttachments.push(moved); + + return { + ...archive, + attachments: nextLooseAttachments, + subDocuments: nextSubDocuments + }; +}; + +/** + * Remove an entire sub-document and return its attachments to the end of the + * loose list. The sub-document ids of the remaining sub-documents are + * preserved. + */ +export const dissolveSubDocument = ( + archive: ProcessedZipArchive, + subDocumentId: string +): ProcessedZipArchive => { + const dissolved = archive.subDocuments.find((item) => item.id === subDocumentId); + + if (!dissolved) { + return archive; + } + + return { + ...archive, + attachments: [...archive.attachments, ...dissolved.attachments], + subDocuments: archive.subDocuments.filter((item) => item.id !== subDocumentId) + }; +}; + +const stripPdfExtension = (name: string) => + name.toLowerCase().endsWith('.pdf') ? name.slice(0, -'.pdf'.length) : name; + +/** + * Build one or more PDF export units from the archive. + * + * - `mode: 'single'` mirrors today's behaviour: one merged PDF covering + * sub-documents first, then loose attachments, named after the archive. + * - `mode: 'separate'` produces one PDF per sub-document (named + * `{archiveName} - {subDocumentName}.pdf`) plus a single merged PDF for any + * loose attachments (named after the archive). Empty groups are skipped. + */ +export const buildArchiveExport = async ( + archive: ProcessedZipArchive, + mode: ExportMode +): Promise => { + if (mode === 'single') { + const attachments = flattenArchiveForMerge(archive); + + if (attachments.length === 0) { + throw new Error('Cannot create a PDF from an empty archive.'); + } + + return [ + { + name: stripPdfExtension(archive.name), + bytes: await mergeAttachments(attachments) + } + ]; + } + + const separateUnits: ExportUnit[] = []; + + for (const subDocument of archive.subDocuments) { + if (subDocument.attachments.length === 0) { + continue; + } + + separateUnits.push({ + name: `${stripPdfExtension(archive.name)} - ${stripPdfExtension(subDocument.name)}`, + bytes: await mergeAttachments(subDocument.attachments) + }); + } + + if (archive.attachments.length > 0) { + separateUnits.push({ + name: stripPdfExtension(archive.name), + bytes: await mergeAttachments(archive.attachments) + }); + } + + if (separateUnits.length === 0) { + throw new Error('Cannot create a PDF from an empty archive.'); + } + + return separateUnits; +}; diff --git a/uat.yml b/uat.yml new file mode 100644 index 0000000..fac4fb8 --- /dev/null +++ b/uat.yml @@ -0,0 +1,37 @@ +name: Build and Push Docker Image + +on: + push: + branches: + - develop + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Docker login to Gitea Registry + run: | + echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login "${{ vars.REGISTRY_URL }}" \ + -u "${{ secrets.REGISTRY_USER }}" \ + --password-stdin + + - name: Build Docker image + run: | + docker build \ + -t "${{ vars.REGISTRY_URL }}/${{ vars.IMAGE_NAME }}:dev" \ + -t "${{ vars.REGISTRY_URL }}/${{ vars.IMAGE_NAME }}:${{ gitea.sha }}" \ + . + + - name: Push Docker image + run: | + docker push "${{ vars.REGISTRY_URL }}/${{ vars.IMAGE_NAME }}:dev" + docker push "${{ vars.REGISTRY_URL }}/${{ vars.IMAGE_NAME }}:${{ gitea.sha }}" + + - name: Deploy + run: | + curl https://dokploy.robosoft-solutions.de/api/deploy/compose/Miw8GJXF_v4Ebavr3mJwa diff --git a/vite.config.ts b/vite.config.ts index 5765ebc..5d92263 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,5 +1,5 @@ import tailwindcss from '@tailwindcss/vite'; -import adapter from '@sveltejs/adapter-auto'; +import adapter from '@sveltejs/adapter-node'; import { sveltekit } from '@sveltejs/kit/vite'; import { defineConfig } from 'vite'; @@ -13,9 +13,6 @@ export default defineConfig({ filename.split(/[/\\]/).includes('node_modules') ? undefined : true }, - // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list. - // If your environment is not supported, or you settled on a specific environment, switch out the adapter. - // See https://svelte.dev/docs/kit/adapters for more information about adapters. adapter: adapter() }) ]