This commit is contained in:
seb
2026-08-04 01:24:19 +02:00
parent c6cb1bc229
commit 4a9403bfd1
6 changed files with 298 additions and 0 deletions

View File

@@ -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}` |

View File

@@ -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 `<fullHash>.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": [

View File

@@ -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 `<fullHash>.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`._

View File

@@ -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 `<fullHash>.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}:

View File

@@ -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);
}

70
src/util/artikelCsv.js Normal file
View File

@@ -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;
}