diff --git a/docs/api/README.md b/docs/api/README.md index 635bfbf..162adac 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -31,6 +31,7 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route | File | Endpoint | |------|----------| | [`routes/article/create.md`](routes/article/create.md) | `POST /article` | +| [`routes/article/export.zip.md`](routes/article/export.zip.md) | `GET /article/export.zip` | | [`routes/article/delete.md`](routes/article/delete.md) | `DELETE /article/{sku}` | | [`routes/article/get.md`](routes/article/get.md) | `GET /article/{sku}` | | [`routes/article/update.md`](routes/article/update.md) | `PATCH /article/{sku}` | diff --git a/docs/api/openapi.json b/docs/api/openapi.json index 5abc516..9944e57 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -2645,6 +2645,84 @@ } } }, + "/article/export.zip": { + "get": { + "tags": [ + "Article" + ], + "summary": "Export articles as ZIP", + "description": "Builds an `artikel.zip` for the authenticated user's selected company.\n`categoryId` (optional) restricts the export to that category and below;\nit must be reachable via the company's root category. Defaults to the\nwhole company root tree.\nThe ZIP contains `artikel.csv` (semicolon-separated, Windows-1252)\nplus one AVIF entry per article image, named `.avif`\n(content-addressed, so shared hashes are deduplicated).\n", + "security": [ + { + "cookieAuth": [] + } + ], + "parameters": [ + { + "in": "query", + "name": "categoryId", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Restrict export to this category subtree" + } + ], + "responses": { + "200": { + "description": "ZIP archive", + "content": { + "application/zip": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "description": "No company selected", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Not authorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Company not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, "/article/{sku}": { "get": { "tags": [ diff --git a/docs/api/routes/article/export.zip.md b/docs/api/routes/article/export.zip.md new file mode 100644 index 0000000..3b2d3a1 --- /dev/null +++ b/docs/api/routes/article/export.zip.md @@ -0,0 +1,37 @@ +# `GET /article/export.zip` + +Protected. Requires a valid session cookie (`credentials: 'include'`). + +Export articles as ZIP + +Builds an `artikel.zip` for the authenticated user's selected company. +`categoryId` (optional) restricts the export to that category and below; +it must be reachable via the company's root category. Defaults to the +whole company root tree. +The ZIP contains `artikel.csv` (semicolon-separated, Windows-1252) +plus one AVIF entry per article image, named `.avif` +(content-addressed, so shared hashes are deduplicated). + + +## Query parameters + +| Param | Required | Type | Description | +|-------|----------|------|-------------| +| `categoryId` | no | integer | Restrict export to this category subtree | + +## Response `200` + +ZIP archive + +## Errors + +| Status | Description | +|--------|-------------| +| 400 | No company selected — `{"error":"…"}` | +| 401 | Unauthorized — `{"error":"…"}` | +| 403 | Not authorized — `{"error":"…"}` | +| 404 | Company not found — `{"error":"…"}` | + +--- + +_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/src/api/routes/article.js b/src/api/routes/article.js index fa9e85b..37ab2e9 100644 --- a/src/api/routes/article.js +++ b/src/api/routes/article.js @@ -1,5 +1,8 @@ import express from 'express'; +import JSZip from 'jszip'; import * as db from '../../db/article.js'; +import * as picDb from '../../db/pic.js'; +import { buildArtikelCsv, toWindows1252 } from '../../util/artikelCsv.js'; const AVIF_BODY_LIMIT = '20mb'; const FULL_HASH_RE = /^[a-fA-F0-9]{64}$/; @@ -138,6 +141,110 @@ export function register(app, basePath = '') { } }); + /** + * @openapi + * /article/export.zip: + * get: + * tags: [Article] + * summary: Export articles as ZIP + * description: | + * Builds an `artikel.zip` for the authenticated user's selected company. + * `categoryId` (optional) restricts the export to that category and below; + * it must be reachable via the company's root category. Defaults to the + * whole company root tree. + * The ZIP contains `artikel.csv` (semicolon-separated, Windows-1252) + * plus one AVIF entry per article image, named `.avif` + * (content-addressed, so shared hashes are deduplicated). + * security: + * - cookieAuth: [] + * parameters: + * - in: query + * name: categoryId + * required: false + * schema: + * type: integer + * description: Restrict export to this category subtree + * responses: + * 200: + * description: ZIP archive + * content: + * application/zip: + * schema: + * type: string + * format: binary + * 400: + * description: No company selected + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 401: + * description: Unauthorized + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 403: + * description: Not authorized + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 404: + * description: Company not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + */ + app.get(`${basePath}/article/export.zip`, async (req, res) => { + const companyId = req.user.selectedCompanyId; + if (companyId == null) { + return res.status(400).json({ error: 'No company selected' }); + } + + let categoryId = null; + if (req.query.categoryId != null && req.query.categoryId !== '') { + const parsed = parsePositiveInt(req.query.categoryId, 'categoryId'); + if (parsed.error) { + return res.status(400).json({ error: parsed.error }); + } + categoryId = parsed.id; + } + + try { + const rows = await db.listExport(req.user.id, companyId, categoryId); + + const csv = buildArtikelCsv(rows); + + const zip = new JSZip(); + zip.file('artikel.csv', toWindows1252(csv)); + + const seen = new Set(); + for (const row of rows) { + for (const fullHash of row.fullHashes ?? []) { + if (!fullHash || seen.has(fullHash)) continue; + seen.add(fullHash); + try { + const { buffer } = await picDb.getImageByHash(req.user.id, fullHash); + zip.file(`${fullHash}.avif`, buffer); + } catch (error) { + // Skip images this user cannot access; CSV already carries the hash. + } + } + } + + const content = await zip.generateAsync({ type: 'nodebuffer' }); + res.setHeader('Content-Type', 'application/zip'); + res.setHeader('Content-Disposition', 'attachment; filename="artikel.zip"'); + res.status(200).send(content); + } catch (error) { + const status = articleErrorStatus(error); + if (status === 500) console.error('Export articles failed:', error); + res.status(status).json({ error: error.message }); + } + }); + /** * @openapi * /article/{sku}: diff --git a/src/db/article.js b/src/db/article.js index 6ce5dc0..7a95b31 100644 --- a/src/db/article.js +++ b/src/db/article.js @@ -64,3 +64,8 @@ export async function listSearchIndex(googleAuthId, companyId) { const store = await getStore(); return store.article.listSearchIndex(googleAuthId, companyId); } + +export async function listExport(googleAuthId, companyId, categoryId) { + const store = await getStore(); + return store.article.listExport(googleAuthId, companyId, categoryId); +} diff --git a/src/util/artikelCsv.js b/src/util/artikelCsv.js new file mode 100644 index 0000000..f8de6e6 --- /dev/null +++ b/src/util/artikelCsv.js @@ -0,0 +1,70 @@ +function csvField(value) { + const text = value == null ? '' : String(value); + return /[";\n\r]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text; +} + +const COLUMNS = [ + 'FullHash', + 'Id', + 'SKU', + 'Name', + 'Slug', + 'Einheit', + 'EinheitSymbol', + 'Menge', + 'Marke', + 'Netto', + 'MwSt', + 'EANs', + 'Kategorien', + 'KategorienPfad', +]; + +function formatNumber(value) { + if (value == null || Number.isNaN(Number(value))) return ''; + return String(value); +} + +/** + * Build artikel.csv from the article export rows returned by `listExport`. + * Rows are sorted by Id (server-side ORDER BY a.id). Semicolon separated with a + * `sep=;` hint; Windows-1252 encoding is applied in the route via toWindows1252. + */ +export function buildArtikelCsv(rows) { + if (!Array.isArray(rows) || rows.length === 0) return ''; + + const lines = [ + 'sep=;', + COLUMNS.map(csvField).join(';'), + ...rows.map((row) => + [ + csvField((row.fullHashes ?? []).join(', ')), + csvField(row.id), + csvField(row.sku), + csvField(row.name ?? ''), + csvField(row.slug ?? ''), + csvField(row.unitSi ?? ''), + csvField(row.unitSymbol ?? ''), + csvField(row.unitamount), + csvField(row.brandName ?? ''), + formatNumber(row.net), + formatNumber(row.taxRate), + csvField((row.eans ?? []).join(', ')), + csvField((row.categoryIds ?? []).join(', ')), + csvField((row.categoryBreadcrumbs ?? []).join(', ')), + ].join(';'), + ), + ]; + + return lines.join('\r\n'); +} + +/** Encode a string as Windows-1252 bytes (single-byte; out-of-range → '?'). */ +export function toWindows1252(str) { + const bytes = Buffer.alloc(str.length); + for (let i = 0; i < str.length; i++) { + const code = str.charCodeAt(i); + bytes[i] = code <= 0xff ? code : 0x3f; // '?' + } + return bytes; +}