From 90b782e5007d691b09a8bd0f016acc2e22a4a00c Mon Sep 17 00:00:00 2001 From: seb Date: Wed, 14 Oct 2026 14:45:45 +0200 Subject: [PATCH] u --- docs/api/README.md | 2 + docs/api/routes-all.md | 8 +- docs/api/routes/company/export.md | 33 ++ docs/api/routes/company/export.post.md | 23 ++ scripts/generateApiDocs.js | 2 + src/api/routes/company.js | 184 ++++++++++ src/services/companyExport.js | 463 +++++++++++++++++++++++++ 7 files changed, 714 insertions(+), 1 deletion(-) create mode 100644 docs/api/routes/company/export.md create mode 100644 docs/api/routes/company/export.post.md create mode 100644 src/services/companyExport.js diff --git a/docs/api/README.md b/docs/api/README.md index 72cf821..ed5e818 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -113,6 +113,8 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route | [`routes/category/_id/property/_propertyId.md`](routes/category/_id/property/_propertyId.md) | `DELETE /category/{id}/property/{propertyId}` | | [`routes/company/list.md`](routes/company/list.md) | `GET /company` | | [`routes/company/create.md`](routes/company/create.md) | `POST /company` | +| [`routes/company/export.md`](routes/company/export.md) | `GET /company/export` | +| [`routes/company/export.post.md`](routes/company/export.post.md) | `POST /company/export` | | [`routes/company/history.md`](routes/company/history.md) | `GET /company/history` | | [`routes/company/addHistory.md`](routes/company/addHistory.md) | `POST /company/history` | | [`routes/company/import.md`](routes/company/import.md) | `POST /company/import` | diff --git a/docs/api/routes-all.md b/docs/api/routes-all.md index 5c456df..4c677c2 100644 --- a/docs/api/routes-all.md +++ b/docs/api/routes-all.md @@ -1,6 +1,6 @@ # Dash API Routes Overview (All) -Overview of all 233 API endpoints across all modules. +Overview of all 235 API endpoints across all modules. ### Notation @@ -340,6 +340,12 @@ _none_ #### Create company — `POST /company` - Params: `body: { iso639: str, name: str, taxrate: num, reducedtaxrate: num }` - Returns: `201 { company: T23, user: T45 }` +#### Export company catalog as zip archive (SSE) — `GET /company/export` +- Params: `query: stream?: str` +- Returns: `200 SSE` +#### Export company catalog as zip archive (SSE) — `POST /company/export` +_none_ +- Returns: `200 SSE` #### Get company history — `GET /company/history` - Params: `query: limit?: int` - Returns: `200 { history: { id: int, date: str, text: str, companyuserId?: int|null }[], truncated: bool }` diff --git a/docs/api/routes/company/export.md b/docs/api/routes/company/export.md new file mode 100644 index 0000000..eca7334 --- /dev/null +++ b/docs/api/routes/company/export.md @@ -0,0 +1,33 @@ +# `GET /company/export` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Export company catalog as zip archive (SSE) + +Exports full company catalog data (categories hierarchy, articles, variants, brands, pictures) +into a round-trip compatible .zip archive matching the format of `/company/import`. +Streams progress events via Server-Sent Events (`text/event-stream`), followed by base64-encoded +binary chunks in `chunk` events and a final `done` event. + + +## Query parameters + +| Param | Required | Type | Description | +|-------|----------|------|-------------| +| `stream` | no | string | Force SSE stream mode (`1`) or direct binary download (`0`) | + +## Response `200` + +Export progress stream (`text/event-stream`) with binary chunks, or raw ZIP file + +## Errors + +| Status | Description | +|--------|-------------| +| 400 | Bad request (no company selected) — `{"error":"…"}` | +| 401 | Unauthorized — `{"error":"…"}` | +| 500 | Server error — `{"error":"…"}` | + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/docs/api/routes/company/export.post.md b/docs/api/routes/company/export.post.md new file mode 100644 index 0000000..41a71d5 --- /dev/null +++ b/docs/api/routes/company/export.post.md @@ -0,0 +1,23 @@ +# `POST /company/export` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Export company catalog as zip archive (SSE) + +POST variant of `/company/export` with identical SSE streaming semantics. + +## Response `200` + +Export progress stream (`text/event-stream`) with binary chunks, or raw ZIP file + +## Errors + +| Status | Description | +|--------|-------------| +| 400 | Bad request (no company selected) — `{"error":"…"}` | +| 401 | Unauthorized — `{"error":"…"}` | +| 500 | Server error — `{"error":"…"}` | + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/scripts/generateApiDocs.js b/scripts/generateApiDocs.js index f09ea0e..e7f4ae5 100644 --- a/scripts/generateApiDocs.js +++ b/scripts/generateApiDocs.js @@ -25,6 +25,8 @@ const OMITTED_ROUTES = new Set([ 'GET /category/export.zip', 'DELETE /company/{id}', 'POST /company', + 'GET /company/export', + 'POST /company/export', 'POST /category/{id}/image', 'POST /delivery/parse', 'GET /events', diff --git a/src/api/routes/company.js b/src/api/routes/company.js index 73767e5..e4e58bd 100644 --- a/src/api/routes/company.js +++ b/src/api/routes/company.js @@ -7,6 +7,7 @@ import * as googleAuth from '../../db/googleAuth.js'; import { clearCachedUser } from '../middleware/auth.js'; import { initCompanyVfs } from '../../tools/vfs.js'; import { executeCompanyImport } from '../../services/companyImport.js'; +import { executeCompanyExport } from '../../services/companyExport.js'; // #region Helpers @@ -605,6 +606,189 @@ export function register(app, basePath = '') { // #endregion + /** + * @openapi + * /company/export: + * get: + * tags: [Company] + * summary: Export company catalog as zip archive (SSE) + * description: | + * Exports full company catalog data (categories hierarchy, articles, variants, brands, pictures) + * into a round-trip compatible .zip archive matching the format of `/company/import`. + * Streams progress events via Server-Sent Events (`text/event-stream`), followed by base64-encoded + * binary chunks in `chunk` events and a final `done` event. + * security: + * - cookieAuth: [] + * x-markdown: company/export.md + * parameters: + * - in: query + * name: stream + * schema: + * type: string + * enum: ['0', '1'] + * description: Force SSE stream mode (`1`) or direct binary download (`0`) + * responses: + * 200: + * description: Export progress stream (`text/event-stream`) with binary chunks, or raw ZIP file + * content: + * text/event-stream: + * schema: + * type: string + * application/zip: + * schema: + * type: string + * format: binary + * 400: + * description: Bad request (no company selected) + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 401: + * description: Unauthorized + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 500: + * description: Server error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * post: + * tags: [Company] + * summary: Export company catalog as zip archive (SSE) + * description: POST variant of `/company/export` with identical SSE streaming semantics. + * security: + * - cookieAuth: [] + * responses: + * 200: + * description: Export progress stream (`text/event-stream`) with binary chunks, or raw ZIP file + * content: + * text/event-stream: + * schema: + * type: string + * application/zip: + * schema: + * type: string + * format: binary + * 400: + * description: Bad request (no company selected) + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 401: + * description: Unauthorized + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 500: + * description: Server error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + */ + // #region GET/POST /company/export + + const handleCompanyExport = async (req, res) => { + let companyId; + try { + companyId = requireSelectedCompany(req); + } catch (error) { + return res.status(error.statusCode || 400).json({ error: error.message }); + } + + const wantsSse = + req.headers.accept?.includes('text/event-stream') || + req.query.stream === '1' || + req.query.sse === '1' || + (!req.headers.accept?.includes('application/zip') && !req.path.endsWith('.zip') && req.query.download !== '1'); + + if (wantsSse) { + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + res.setHeader('X-Accel-Buffering', 'no'); + if (typeof res.flushHeaders === 'function') { + res.flushHeaders(); + } + + const sseWrite = (event, data) => { + if (res.writableEnded || res.destroyed) return; + const payload = typeof data === 'string' ? data : JSON.stringify(data); + res.write(`event: ${event}\ndata: ${payload}\n\n`); + if (typeof res.flush === 'function') res.flush(); + }; + + sseWrite('status', { message: 'Export gestartet. Daten werden verarbeitet...' }); + + try { + const outcome = await executeCompanyExport({ + googleAuthId: req.user.id, + companyId, + onStatus: (status) => sseWrite('status', status), + onProgress: (counts) => sseWrite('progress', counts), + }); + + // Stream binary chunks as base64 in SSE 'chunk' events + const CHUNK_SIZE = 64 * 1024; // 64 KB binary slice per SSE event + const { zipBuffer, filename, stats } = outcome; + const totalChunks = Math.ceil(zipBuffer.length / CHUNK_SIZE); + + sseWrite('status', { + message: `Sende Archiv (${(zipBuffer.length / (1024 * 1024)).toFixed(2)} MB in ${totalChunks} Chunks)...`, + }); + + for (let i = 0; i < totalChunks; i++) { + if (res.writableEnded || res.destroyed) break; + const slice = zipBuffer.subarray(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE); + const base64 = slice.toString('base64'); + res.write(`event: chunk\ndata: ${base64}\n\n`); + if (typeof res.flush === 'function') res.flush(); + } + + sseWrite('done', { + ok: true, + filename, + size: zipBuffer.length, + totalChunks, + stats, + }); + res.end(); + } catch (err) { + console.error('[Company Export] Export execution failed:', err); + sseWrite('error', { message: err.message || 'Export fehlgeschlagen' }); + res.end(); + } + } else { + // Direct binary download + try { + const outcome = await executeCompanyExport({ + googleAuthId: req.user.id, + companyId, + }); + + res.setHeader('Content-Type', 'application/zip'); + res.setHeader('Content-Disposition', `attachment; filename="${outcome.filename}"`); + res.status(200).send(outcome.zipBuffer); + } catch (err) { + console.error('[Company Export] Export failed:', err); + const status = companyErrorStatus(err); + res.status(status).json({ error: err.message || 'Export fehlgeschlagen' }); + } + } + }; + + app.get(`${basePath}/company/export`, handleCompanyExport); + app.get(`${basePath}/company/export.zip`, handleCompanyExport); + app.post(`${basePath}/company/export`, handleCompanyExport); + + // #endregion + /** * @openapi * /company/{id}: diff --git a/src/services/companyExport.js b/src/services/companyExport.js new file mode 100644 index 0000000..14b31f1 --- /dev/null +++ b/src/services/companyExport.js @@ -0,0 +1,463 @@ +// MARK: - Imports +// #region Imports +import JSZip from 'jszip'; +import { getStore } from '../db/store.js'; +import { createLogger } from '../util/logger.js'; +// #endregion + +const log = createLogger('export'); + +// MARK: - CSV Helpers +// #region CSV Helpers + +/** + * Formats a value as a double-quoted CSV field. + * + * @param {any} val + * @returns {string} + */ +function formatCsvField(val) { + if (val == null) return '""'; + const str = String(val); + return `"${str.replace(/"/g, '""')}"`; +} + +/** + * Formats a number in German decimal format (e.g. 8.36 -> "8,36"). + * + * @param {number|string|null} val + * @param {string} [fallback='0'] + * @returns {string} + */ +function formatGermanFloat(val, fallback = '0') { + if (val == null || val === '') return fallback; + const num = Number(val); + if (Number.isNaN(num)) return fallback; + return String(num).replace('.', ','); +} + +/** + * Extracts Pfand deposit amount from article options if present. + * + * @param {Array} options + * @returns {string} e.g. "pfand:0.25" or "" + */ +function extractPfandAttribute(options) { + if (!Array.isArray(options) || options.length === 0) return ''; + for (const opt of options) { + const sku = String(opt.sku || '').toUpperCase(); + const match = sku.match(/PFAND[-_]?([0-9]+(?:[.,][0-9]+)?)/i); + if (match) { + const val = parseFloat(match[1].replace(',', '.')); + if (Number.isFinite(val) && val > 0) { + return `pfand:${val.toFixed(2)}`; + } + } + if (opt.name) { + const nameMatch = String(opt.name).match(/pfand\s*([0-9]+(?:[.,][0-9]+)?)/i); + if (nameMatch) { + const val = parseFloat(nameMatch[1].replace(',', '.')); + if (Number.isFinite(val) && val > 0) { + return `pfand:${val.toFixed(2)}`; + } + } + } + } + return ''; +} + +// #endregion + +// MARK: - Concurrency Pool +// #region Concurrency Pool + +/** + * Runs tasks with a controlled concurrency limit. + * + * @template T, R + * @param {Array} items + * @param {number} concurrency + * @param {(item: T, index: number) => Promise} fn + * @returns {Promise>} + */ +async function asyncPool(items, concurrency, fn) { + const results = new Array(items.length); + let index = 0; + + const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => { + while (index < items.length) { + const current = index++; + results[current] = await fn(items[current], current); + } + }); + + await Promise.all(workers); + return results; +} + +// #endregion + +// MARK: - Company Export Orchestrator +// #region Company Export Orchestrator + +/** + * Executes full catalog export from store into a round-trip compatible ZIP buffer. + * + * @param {object} opts + * @param {number} opts.googleAuthId + * @param {number} opts.companyId + * @param {function} [opts.onStatus] - (statusObj) => void + * @param {function} [opts.onProgress] - (counts) => void + * @returns {Promise<{ zipBuffer: Buffer, stats: object, filename: string }>} + */ +export async function executeCompanyExport({ + googleAuthId, + companyId, + onStatus = () => {}, + onProgress = () => {}, +}) { + const store = await getStore(); + const zip = new JSZip(); + + // 1. Categories + onStatus({ message: 'Lade Kategorien...' }); + const treeResult = await store.category.getTreeForCompany(googleAuthId); + const categoryTree = treeResult.tree || []; + + onProgress({ + stage: 'categories', + categoriestotal: categoryTree.length, + categoriesdone: categoryTree.length, + percent: 10, + }); + + // 2. Brands + onStatus({ message: 'Lade Hersteller / Marken...' }); + const brands = []; + let brandOffset = 0; + const brandLimit = 100; + while (true) { + const brandListRes = await store.brand.list(googleAuthId, { offset: brandOffset, limit: brandLimit }); + const batch = brandListRes.brands || []; + brands.push(...batch); + brandOffset += batch.length; + if (batch.length < brandLimit || brandOffset >= (brandListRes.total ?? 0)) { + break; + } + } + + const brandById = new Map(); + const brandByName = new Map(); + for (const b of brands) { + brandById.set(b.id, b); + if (b.name) brandByName.set(b.name.trim().toLowerCase(), b); + } + + onProgress({ + stage: 'brands', + brandstotal: brands.length, + brandsdone: brands.length, + percent: 15, + }); + + // 3. Articles + onStatus({ message: 'Lade Artikelliste...' }); + const exportArticles = await store.article.listExport(googleAuthId, null); + const totalArticles = exportArticles.length; + + onProgress({ + stage: 'articles', + articlestotal: totalArticles, + articlesdone: 0, + percent: 20, + }); + + onStatus({ message: `Lade Detaildaten für ${totalArticles} Artikel...` }); + let articlesProcessed = 0; + const fullArticles = await asyncPool(exportArticles, 5, async (item) => { + try { + const detail = await store.article.getBySku(googleAuthId, item.sku); + articlesProcessed++; + if (articlesProcessed % 50 === 0 || articlesProcessed === totalArticles) { + onProgress({ + stage: 'articles', + articlestotal: totalArticles, + articlesdone: articlesProcessed, + percent: 20 + Math.floor((articlesProcessed / totalArticles) * 40), + }); + } + return detail.article; + } catch (err) { + log.warn(`Could not fetch details for article ${item.sku}:`, err.message); + articlesProcessed++; + return { + id: item.id, + sku: item.sku, + name: item.name, + net: item.net, + taxRate: item.taxRate, + track_stock: item.track_stock, + eans: item.eans || [], + categoryIds: item.categoryIds || [], + photos: (item.fullHashes || []).map((h) => ({ fullHash: h })), + brand: item.brandName ? { name: item.brandName } : null, + }; + } + }); + + // Build parent-child lookup for variant linking + const articleBySku = new Map(); + const articleById = new Map(); + for (const a of fullArticles) { + if (a.sku) articleBySku.set(a.sku, a); + if (a.id) articleById.set(a.id, a); + } + + // Populate attributeValue for children from parent's children array if needed + for (const a of fullArticles) { + if (a.isParent && Array.isArray(a.children)) { + for (const childRef of a.children) { + const childObj = articleBySku.get(childRef.sku) || articleById.get(childRef.id); + if (childObj && !childObj.attributeValue && childRef.attributeValue) { + childObj.attributeValue = childRef.attributeValue; + } + } + } + } + + // 4. Collect Images + onStatus({ message: 'Sammle Bildreferenzen...' }); + const uniqueHashes = new Set(); + + // Category pictures + for (const c of categoryTree) { + if (c.fullHash) uniqueHashes.add(c.fullHash); + } + + // Brand logos + for (const b of brands) { + if (b.pics?.[0]?.fullHash) { + uniqueHashes.add(b.pics[0].fullHash); + } + } + + // Article pictures + for (const a of fullArticles) { + for (const p of a.photos || []) { + if (p.fullHash) uniqueHashes.add(p.fullHash); + } + if (a.brand?.fullHash) { + uniqueHashes.add(a.brand.fullHash); + } + } + + const hashList = Array.from(uniqueHashes); + const totalImages = hashList.length; + onProgress({ + stage: 'images', + imagestotal: totalImages, + imagesdone: 0, + percent: 60, + }); + + onStatus({ message: `Lade ${totalImages} Bilddateien...` }); + const hashToBuffer = new Map(); + let imagesProcessed = 0; + + await asyncPool(hashList, 5, async (fullHash) => { + try { + const pic = await store.pic.getImageByHash(googleAuthId, fullHash); + if (pic?.buffer) { + hashToBuffer.set(fullHash, pic.buffer); + } + } catch (err) { + log.warn(`Could not load image hash ${fullHash}:`, err.message); + } + imagesProcessed++; + if (imagesProcessed % 25 === 0 || imagesProcessed === totalImages) { + onProgress({ + stage: 'images', + imagestotal: totalImages, + imagesdone: imagesProcessed, + percent: 60 + Math.floor((imagesProcessed / Math.max(totalImages, 1)) * 25), + }); + } + }); + + // 5. Build categories.csv & place category images in ZIP + onStatus({ message: 'Erstelle categories.csv...' }); + const categoryCsvRows = [ + ['"id"', '"parentid"', '"sort"', '"name"', '"pictureFiles"'].join(';'), + ]; + + for (const c of categoryTree) { + let picRel = ''; + if (c.fullHash && hashToBuffer.has(c.fullHash)) { + picRel = `pictures/categories/${c.id}/${c.fullHash}.avif`; + zip.file(picRel, hashToBuffer.get(c.fullHash)); + } + + const row = [ + formatCsvField(c.id), + formatCsvField(c.parentId == null ? '0' : String(c.parentId)), + formatCsvField(c.sort ?? 0), + formatCsvField(c.name ?? ''), + formatCsvField(picRel), + ]; + categoryCsvRows.push(row.join(';')); + } + zip.file('categories.csv', categoryCsvRows.join('\r\n')); + + // 6. Place brand images in ZIP + for (const b of brands) { + const hash = b.pics?.[0]?.fullHash; + if (hash && hashToBuffer.has(hash)) { + const rel = `pictures/brands/${b.id}/${hash}.avif`; + zip.file(rel, hashToBuffer.get(hash)); + } + } + + // 7. Build articles.csv & place article images in ZIP + onStatus({ message: 'Erstelle articles.csv...' }); + const articleCsvHeaders = [ + '"id"', + '"sku"', + '"name"', + '"net"', + '"taxrate"', + '"active"', + '"stock"', + '"ean"', + '"weight"', + '"track_strock"', + '"created_at"', + '"sort"', + '"variation"', + '"parentid"', + '"is_parent"', + '"brand"', + '"categories"', + '"attributes"', + '"brandPictureFiles"', + '"pictureFiles"', + '"desc"', + ]; + const articleCsvRows = [articleCsvHeaders.join(';')]; + + for (const a of fullArticles) { + // Collect article pictures + const picPaths = []; + for (const p of a.photos || []) { + if (p.fullHash && hashToBuffer.has(p.fullHash)) { + const rel = `pictures/articles/${a.id}/${p.fullHash}.avif`; + zip.file(rel, hashToBuffer.get(p.fullHash)); + picPaths.push(rel); + } + } + + // Brand image path + let brandPicRel = ''; + const brandObj = a.brand?.id + ? brandById.get(a.brand.id) + : a.brand?.name + ? brandByName.get(a.brand.name.trim().toLowerCase()) + : null; + + const brandHash = brandObj?.pics?.[0]?.fullHash || a.brand?.fullHash; + if (brandHash && hashToBuffer.has(brandHash) && (brandObj?.id || a.brand?.id)) { + const bId = brandObj?.id || a.brand.id; + brandPicRel = `pictures/brands/${bId}/${brandHash}.avif`; + } + + // Price & Tax + const netPrice = a.prices?.[0]?.net ?? a.net ?? 0; + const taxRate = a.prices?.[0]?.taxRate ?? a.taxRate ?? 19; + + // Parent / Variant fields + const isParent = Boolean(a.isParent); + let parentId = '0'; + let variation = ''; + + if (isParent) { + variation = a.parentAttributeName || 'Variante'; + } else if (a.parentArticleId != null || a.parentSku != null) { + const parentObj = a.parentArticleId + ? articleById.get(a.parentArticleId) + : articleBySku.get(a.parentSku); + parentId = String(parentObj?.id ?? a.parentArticleId ?? 0); + variation = a.attributeValue || 'Standard'; + } + + // Pfand attribute + const pfandAttr = extractPfandAttribute(a.options); + + // Barcode / EAN + const ean = Array.isArray(a.eans) && a.eans.length > 0 ? a.eans[0] : ''; + + // Categories + const catStr = Array.isArray(a.categoryIds) ? a.categoryIds.join(';') : ''; + + const row = [ + formatCsvField(a.id), + formatCsvField(a.sku), + formatCsvField(a.name ?? ''), + formatCsvField(formatGermanFloat(netPrice)), + formatCsvField(formatGermanFloat(taxRate, '19')), + formatCsvField('1'), // active + formatCsvField(a.stock ?? 0), + formatCsvField(ean), + formatCsvField(formatGermanFloat(a.weight ?? 0)), + formatCsvField(a.track_stock ? '1' : '0'), + formatCsvField(a.createdAt || new Date().toISOString()), + formatCsvField(a.sort ?? 0), + formatCsvField(variation), + formatCsvField(parentId), + formatCsvField(isParent ? '1' : '0'), + formatCsvField(a.brand?.name || ''), + formatCsvField(catStr), + formatCsvField(pfandAttr), + formatCsvField(brandPicRel), + formatCsvField(picPaths.join(';')), + formatCsvField(a.desc || ''), + ]; + articleCsvRows.push(row.join(';')); + } + zip.file('articles.csv', articleCsvRows.join('\r\n')); + + // 8. Generate ZIP buffer + onStatus({ message: 'Erstelle ZIP-Archiv...' }); + onProgress({ + stage: 'packaging', + percent: 92, + }); + + const zipBuffer = await zip.generateAsync({ + type: 'nodebuffer', + compression: 'DEFLATE', + compressionOptions: { level: 6 }, + }); + + onProgress({ + stage: 'packaging', + percent: 100, + }); + + const filename = `company-${companyId}-export.zip`; + const stats = { + categories: categoryTree.length, + brands: brands.length, + articles: fullArticles.length, + images: hashToBuffer.size, + zipSize: zipBuffer.length, + }; + + log.info(`Company export complete: ${filename} (${zipBuffer.length} bytes, ${stats.articles} articles, ${stats.images} images)`); + + return { + zipBuffer, + stats, + filename, + }; +} + +// #endregion