From 8a5ee9fe20108be8f90b84419ffaed05d2670112 Mon Sep 17 00:00:00 2001 From: rehlert Date: Mon, 15 Jun 2026 20:13:39 +0200 Subject: [PATCH] adds agents.md, splits zip-processing.ts --- .idea/workspace.xml | 24 ++- agents.md | 50 ++++++ src/lib/services/xml-reading.service.ts | 93 +++++++++++ src/lib/services/zip-inflating.service.ts | 13 ++ src/lib/zip-processing.ts | 182 +++++----------------- src/routes/+page.svelte | 2 +- 6 files changed, 209 insertions(+), 155 deletions(-) create mode 100644 agents.md create mode 100644 src/lib/services/xml-reading.service.ts create mode 100644 src/lib/services/zip-inflating.service.ts diff --git a/.idea/workspace.xml b/.idea/workspace.xml index e595adb..2019e4c 100644 --- a/.idea/workspace.xml +++ b/.idea/workspace.xml @@ -3,11 +3,8 @@ - - - - + @@ -166,7 +173,8 @@ - diff --git a/agents.md b/agents.md new file mode 100644 index 0000000..8be9a8f --- /dev/null +++ b/agents.md @@ -0,0 +1,50 @@ +# Scout findings + +## Project snapshot +- **Tech stack:** SvelteKit + TypeScript + Vite + Tailwind v4. +- **App name/branding:** `beA-Edit`. +- **Core purpose:** upload one or more ZIP files, extract relevant PDFs/images, preview them, optionally reorder attachments, and download a merged PDF. + +## Main user flow +1. User adds ZIP files via drag/drop or file picker (`ZipDropzone`). +2. `+page.svelte` starts async extraction for each file. +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`. + +## Important source files +- `src/routes/+page.svelte` — top-level page state, file selection, localStorage-backed thumbnail width setting, async ZIP processing. +- `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/AttachmentPreview.svelte` — preview generation for images/PDFs. +- `src/lib/components/Card.svelte` — generic page container. + +## ZIP/PDF processing details +- ZIP entries are filtered to ignore macOS metadata (`__MACOSX`, `.DS_Store`, `._*`). +- Relevant attachments are PDFs and common image formats. +- Nested ZIP archives are processed recursively. +- `xjustiz_nachricht.xml` is used to derive archive naming and ordering. +- Ordering prefers document names from metadata before falling back to original attachment order. +- PDF merging uses `pdf-lib`; images are embedded as PNG/JPG, with browser-side canvas conversion for other image types. + +## UI/state details +- Thumbnail width is stored in `localStorage` under `thumbnailWidth` and clamped to `100..400`. +- Processing progress is shown as a count of pending ZIP files. +- The editor supports manual attachment reordering via drag-and-drop. + +## Dependencies worth noting +- `fflate` for ZIP extraction +- `pdf-lib` for PDF generation/merging +- `pdfjs-dist` for PDF thumbnail previews +- `@skeletonlabs/skeleton` / `@skeletonlabs/skeleton-svelte` for styling components + +## Current working tree observations +- `agents.md` is newly created. +- Existing local changes are present in: + - `src/lib/zip-processing.ts` + - `src/routes/+page.svelte` +- New/untracked directory present: + - `src/lib/services/` diff --git a/src/lib/services/xml-reading.service.ts b/src/lib/services/xml-reading.service.ts new file mode 100644 index 0000000..a35f855 --- /dev/null +++ b/src/lib/services/xml-reading.service.ts @@ -0,0 +1,93 @@ +export type XJustizMetadata = { + sender: string; + receiver: string; + documentNames: string[]; +}; + +const XML_TEXT_DECODER = new TextDecoder(); + +const matchesElementName = (element: Element, requestedName: string) => { + const requestedLocalName = requestedName.split('.').pop() ?? requestedName; + + return ( + element.localName === requestedName || + element.localName === requestedLocalName || + element.tagName === requestedName || + element.tagName === requestedLocalName || + element.tagName.endsWith(`:${requestedLocalName}`) + ); +}; + +const getDirectChildByLocalName = (element: Element, localName: string) => + Array.from(element.children).find((child) => matchesElementName(child, localName)) ?? null; + +const getFirstDescendantByLocalName = (root: Document | Element, localName: string) => + Array.from(root.querySelectorAll('*')).find((element) => + matchesElementName(element, localName) + ) ?? null; + +const getTextContentByPath = (root: Element, path: string[]) => { + let current: Element | null = root; + + for (const localName of path) { + current = current ? getDirectChildByLocalName(current, localName) : null; + + if (!current) { + return null; + } + } + + const textContent = current.textContent?.trim(); + + return textContent ? textContent : null; +}; + +const getTextContentByLocalName = (root: Document | Element, localName: string) => + getFirstDescendantByLocalName(root, localName)?.textContent?.trim() ?? null; + +const parseXmlDocument = (xmlBytes: Uint8Array) => { + const parser = new DOMParser(); + const document = parser.parseFromString(XML_TEXT_DECODER.decode(xmlBytes), 'application/xml'); + + if (document.querySelector('parsererror')) { + return null; + } + + return document; +}; + +export const parseXJustizMetadata = (xmlBytes: Uint8Array): XJustizMetadata | null => { + const document = parseXmlDocument(xmlBytes); + + if (!document) { + return null; + } + + const sender = getTextContentByLocalName(document, 'aktenzeichen.absender') ?? ''; + const receiver = getTextContentByLocalName(document, 'aktenzeichen.empfaenger') ?? ''; + const schriftgutobjekte = getFirstDescendantByLocalName(document, 'schriftgutobjekte'); + + if (!schriftgutobjekte) { + return { + sender, + receiver, + documentNames: [] + }; + } + + const dokumentNodes = Array.from(schriftgutobjekte.children).filter((child) => + matchesElementName(child, 'dokument') + ); + + const documentNames = dokumentNodes + .map((dokument) => + getTextContentByPath(dokument, ['xjustiz.fachspezifischeDaten', 'datei', 'dateiname']) + ) + .filter((name): name is string => Boolean(name)); + + return { + sender, + receiver, + documentNames + }; +}; diff --git a/src/lib/services/zip-inflating.service.ts b/src/lib/services/zip-inflating.service.ts new file mode 100644 index 0000000..4082758 --- /dev/null +++ b/src/lib/services/zip-inflating.service.ts @@ -0,0 +1,13 @@ +import { unzip } from 'fflate'; + +export const unzipArchive = (data: Uint8Array) => + new Promise>((resolve, reject) => { + unzip(data, (error, files) => { + if (error) { + reject(error); + return; + } + + resolve(files); + }); + }); diff --git a/src/lib/zip-processing.ts b/src/lib/zip-processing.ts index 829d740..a0f6645 100644 --- a/src/lib/zip-processing.ts +++ b/src/lib/zip-processing.ts @@ -1,6 +1,8 @@ -import { unzip } from 'fflate'; import { PDFDocument } from 'pdf-lib'; +import { parseXJustizMetadata, type XJustizMetadata } from './services/xml-reading.service'; +import { unzipArchive } from './services/zip-inflating.service'; + export type ZipAttachmentKind = 'pdf' | 'image'; export type ZipAttachment = { @@ -15,12 +17,6 @@ export type ProcessedZipArchive = { attachments: ZipAttachment[]; }; -type XJustizMetadata = { - sender: string; - receiver: string; - documentNames: string[]; -}; - const IMAGE_EXTENSIONS = new Set([ '.avif', '.bmp', @@ -36,22 +32,22 @@ const IMAGE_EXTENSIONS = new Set([ ]); const ZIP_META_FILE_NAME = 'xjustiz_nachricht.xml'; -const XML_TEXT_DECODER = new TextDecoder(); - -const unzipArchive = (data: Uint8Array) => - new Promise>((resolve, reject) => { - unzip(data, (error, files) => { - if (error) { - reject(error); - return; - } - - resolve(files); - }); - }); const getBaseName = (path: string) => path.split('/').pop() ?? path; +const getCurrentDateString = (date: Date) => + `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; + +const createArchiveName = (date: Date, metadata: XJustizMetadata | null) => { + const datePrefix = getCurrentDateString(date); + + if (!metadata) { + return `${datePrefix}_unbekannt_unbekannt.pdf`; + } + + return `${datePrefix}_${metadata.sender}_${metadata.receiver}.pdf`; +}; + const getExtension = (path: string) => { const baseName = getBaseName(path).toLowerCase(); const lastDotIndex = baseName.lastIndexOf('.'); @@ -78,93 +74,6 @@ const isMacMetadataEntry = (path: string) => { baseName.startsWith('._') ); }; - -const matchesElementName = (element: Element, requestedName: string) => { - const requestedLocalName = requestedName.split('.').pop() ?? requestedName; - - return ( - element.localName === requestedName || - element.localName === requestedLocalName || - element.tagName === requestedName || - element.tagName === requestedLocalName || - element.tagName.endsWith(`:${requestedLocalName}`) - ); -}; - -const getDirectChildByLocalName = (element: Element, localName: string) => - Array.from(element.children).find((child) => matchesElementName(child, localName)) ?? null; - -const getFirstDescendantByLocalName = (root: Document | Element, localName: string) => - Array.from(root.querySelectorAll('*')).find((element) => - matchesElementName(element, localName) - ) ?? null; - -const getTextContentByPath = (root: Element, path: string[]) => { - let current: Element | null = root; - - for (const localName of path) { - current = current ? getDirectChildByLocalName(current, localName) : null; - - if (!current) { - return null; - } - } - - const textContent = current.textContent?.trim(); - - return textContent ? textContent : null; -}; - -const getTextContentByLocalName = (root: Document | Element, localName: string) => - getFirstDescendantByLocalName(root, localName)?.textContent?.trim() ?? null; - -const parseXmlDocument = (xmlBytes: Uint8Array) => { - const parser = new DOMParser(); - const document = parser.parseFromString(XML_TEXT_DECODER.decode(xmlBytes), 'application/xml'); - - if (document.querySelector('parsererror')) { - return null; - } - - return document; -}; - -const parseXJustizMetadata = (xmlBytes: Uint8Array): XJustizMetadata | null => { - const document = parseXmlDocument(xmlBytes); - - if (!document) { - return null; - } - - const sender = getTextContentByLocalName(document, 'aktenzeichen.absender') ?? ''; - const receiver = getTextContentByLocalName(document, 'aktenzeichen.empfaenger') ?? ''; - const schriftgutobjekte = getFirstDescendantByLocalName(document, 'schriftgutobjekte'); - - if (!schriftgutobjekte) { - return { - sender, - receiver, - documentNames: [] - }; - } - - const dokumentNodes = Array.from(schriftgutobjekte.children).filter((child) => - matchesElementName(child, 'dokument') - ); - - const documentNames = dokumentNodes - .map((dokument) => - getTextContentByPath(dokument, ['xjustiz.fachspezifischeDaten', 'datei', 'dateiname']) - ) - .filter((name): name is string => Boolean(name)); - - return { - sender, - receiver, - documentNames - }; -}; - const orderAttachmentsByDocumentNames = (attachments: ZipAttachment[], documentNames: string[]) => { const buckets = new Map(); @@ -216,61 +125,48 @@ const orderAttachmentsByDocumentNames = (attachments: ZipAttachment[], documentN return orderedAttachments; }; -const extractZipEntries = async ( - archiveBytes: Uint8Array, - _fallbackName: string -): Promise => { +const shouldSkipZipEntry = (path: string) => isMacMetadataEntry(path); + +const isMetadataFile = (path: string) => getBaseName(path).toLowerCase() === ZIP_META_FILE_NAME; + +const createAttachment = (path: string, data: Uint8Array): ZipAttachment => ({ + name: getBaseName(path), + path, + kind: isPdf(path) ? 'pdf' : 'image', + data +}); + +const extractZipEntries = async (archiveBytes: Uint8Array): Promise => { const files = await unzipArchive(archiveBytes); const attachments: ZipAttachment[] = []; const nestedArchives: ProcessedZipArchive[] = []; let xjustizNachrichtXml: Uint8Array | null = null; for (const [path, data] of Object.entries(files)) { - const baseName = getBaseName(path); - - if (isMacMetadataEntry(path)) { + if (shouldSkipZipEntry(path)) { continue; } - if (baseName.toLowerCase() === ZIP_META_FILE_NAME) { + if (isMetadataFile(path)) { xjustizNachrichtXml = data; continue; } if (isZipArchive(path)) { - const childArchives = await extractZipEntries(data, baseName); + const childArchives = await extractZipEntries(data); nestedArchives.push(...childArchives); continue; } - if (isPdf(path)) { - attachments.push({ - name: baseName, - path, - kind: 'pdf', - data - }); - continue; - } - - if (isImage(path)) { - attachments.push({ - name: baseName, - path, - kind: 'image', - data - }); + if (isPdf(path) || isImage(path)) { + attachments.push(createAttachment(path, data)); } } const metadata = xjustizNachrichtXml ? parseXJustizMetadata(xjustizNachrichtXml) : null; const documentNames = metadata?.documentNames ?? []; const orderedAttachments = orderAttachmentsByDocumentNames(attachments, documentNames); - const now = new Date(); - const date = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; - const archiveName = metadata - ? `${date}_${metadata.sender}_${metadata.receiver}.pdf` - : `${date}_unbekannt_unbekannt.pdf`; + const archiveName = createArchiveName(new Date(), metadata); const archives: ProcessedZipArchive[] = []; if (orderedAttachments.length > 0) { @@ -286,7 +182,7 @@ const extractZipEntries = async ( export const extractZipArchives = async (file: File): Promise => { const archiveBytes = new Uint8Array(await file.arrayBuffer()); - return extractZipEntries(archiveBytes, file.name); + return extractZipEntries(archiveBytes); }; const loadImageElement = (src: string) => @@ -361,9 +257,7 @@ const embedAttachmentImage = async (pdfDocument: PDFDocument, attachment: ZipAtt }; const mergeAttachmentIntoPdf = async (mergedPdf: PDFDocument, attachment: ZipAttachment) => { - const canLoadAsPdf = attachment.kind === 'pdf' - - if (canLoadAsPdf) { + if (attachment.kind === 'pdf') { const sourcePdf = await PDFDocument.load(attachment.data, { ignoreEncryption: true }); const copiedPages = await mergedPdf.copyPages(sourcePdf, sourcePdf.getPageIndices()); @@ -385,10 +279,6 @@ const mergeAttachmentIntoPdf = async (mergedPdf: PDFDocument, attachment: ZipAtt height: page.getHeight() }); } catch (error) { - if (attachment.kind === 'pdf') { - throw new Error(`Attachment "${attachment.name}" is not a valid PDF and could not be rendered as an image.`); - } - throw error; } }; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 54aa1ea..4af9325 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -107,7 +107,7 @@